|
|
@@ -1,6 +1,10 @@
|
|
|
import { useState, useEffect } from 'react'
|
|
|
+import { Link, useSearchParams } from 'react-router-dom'
|
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
|
-import { Plus, Search, Pencil } from 'lucide-react'
|
|
|
+import {
|
|
|
+ Plus, Search, Pencil, ChevronLeft, ChevronRight, Database,
|
|
|
+ Copy, ArrowUp, ArrowDown, ArrowUpDown, RefreshCw,
|
|
|
+} from 'lucide-react'
|
|
|
import { api } from '@/api/client'
|
|
|
import { useAuthStore } from '@/stores/authStore'
|
|
|
import { useToastStore } from '@/stores/toastStore'
|
|
|
@@ -33,6 +37,8 @@ function buildQueryString(
|
|
|
filterValue: string,
|
|
|
limit: string,
|
|
|
offset: string,
|
|
|
+ sortField: string,
|
|
|
+ sortDesc: boolean,
|
|
|
): string {
|
|
|
const parts: string[] = []
|
|
|
|
|
|
@@ -42,6 +48,11 @@ function buildQueryString(
|
|
|
}
|
|
|
if (limit.trim()) parts.push(`limit=${encodeURIComponent(limit.trim())}`)
|
|
|
if (offset.trim()) parts.push(`offset=${encodeURIComponent(offset.trim())}`)
|
|
|
+ // Ordering is applied server-side via the `sort`/`desc` query params.
|
|
|
+ if (sortField.trim()) {
|
|
|
+ parts.push(`sort=${encodeURIComponent(sortField.trim())}`)
|
|
|
+ if (sortDesc) parts.push('desc=true')
|
|
|
+ }
|
|
|
|
|
|
return parts.length > 0 ? `?${parts.join('&')}` : ''
|
|
|
}
|
|
|
@@ -282,9 +293,13 @@ function DocEditorModal({
|
|
|
interface ResultsTableProps {
|
|
|
docs: Record<string, unknown>[]
|
|
|
onRowClick: (doc: Record<string, unknown>) => void
|
|
|
+ onCopy: (text: string, label: string) => void
|
|
|
+ sortField: string
|
|
|
+ sortDesc: boolean
|
|
|
+ onSortById: () => void
|
|
|
}
|
|
|
|
|
|
-function ResultsTable({ docs, onRowClick }: ResultsTableProps) {
|
|
|
+function ResultsTable({ docs, onRowClick, onCopy, sortField, sortDesc, onSortById }: ResultsTableProps) {
|
|
|
if (docs.length === 0) {
|
|
|
return (
|
|
|
<div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-10 text-center">
|
|
|
@@ -293,38 +308,83 @@ function ResultsTable({ docs, onRowClick }: ResultsTableProps) {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
+ const idActive = sortField === 'id'
|
|
|
+ const IdSortIcon = idActive ? (sortDesc ? ArrowDown : ArrowUp) : ArrowUpDown
|
|
|
+
|
|
|
return (
|
|
|
<div className="rounded-xl border border-slate-700/40 overflow-hidden">
|
|
|
<table className="w-full text-sm">
|
|
|
<thead>
|
|
|
<tr className="border-b border-slate-700/40 bg-slate-800/60">
|
|
|
- <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider w-40">ID</th>
|
|
|
+ <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider w-40">
|
|
|
+ <button
|
|
|
+ onClick={onSortById}
|
|
|
+ className={[
|
|
|
+ 'inline-flex items-center gap-1 hover:text-slate-200 transition',
|
|
|
+ idActive ? 'text-indigo-300' : '',
|
|
|
+ ].join(' ')}
|
|
|
+ data-testid="doc-sort-id"
|
|
|
+ title="Sort by id (server-side)"
|
|
|
+ >
|
|
|
+ ID
|
|
|
+ <IdSortIcon size={12} className={idActive ? '' : 'opacity-40'} />
|
|
|
+ </button>
|
|
|
+ </th>
|
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Preview</th>
|
|
|
- <th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider w-16">Edit</th>
|
|
|
+ <th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider w-28">Actions</th>
|
|
|
</tr>
|
|
|
</thead>
|
|
|
<tbody>
|
|
|
- {docs.map((doc, idx) => (
|
|
|
- <tr
|
|
|
- key={idx}
|
|
|
- onClick={() => onRowClick(doc)}
|
|
|
- className={[
|
|
|
- 'border-b border-slate-700/20 cursor-pointer transition hover:bg-indigo-500/5',
|
|
|
- idx % 2 === 0 ? 'bg-slate-900/30' : 'bg-slate-800/20',
|
|
|
- ].join(' ')}
|
|
|
- data-testid="doc-row"
|
|
|
- >
|
|
|
- <td className="px-4 py-3 font-mono text-slate-300 text-xs truncate max-w-xs">
|
|
|
- {docId(doc)}
|
|
|
- </td>
|
|
|
- <td className="px-4 py-3 font-mono text-slate-400 text-xs truncate max-w-md">
|
|
|
- {compactPreview(doc)}
|
|
|
- </td>
|
|
|
- <td className="px-4 py-3 text-right">
|
|
|
- <Pencil size={13} className="inline text-slate-500" />
|
|
|
- </td>
|
|
|
- </tr>
|
|
|
- ))}
|
|
|
+ {docs.map((doc, idx) => {
|
|
|
+ const id = docId(doc)
|
|
|
+ return (
|
|
|
+ <tr
|
|
|
+ key={idx}
|
|
|
+ onClick={() => onRowClick(doc)}
|
|
|
+ className={[
|
|
|
+ 'border-b border-slate-700/20 cursor-pointer transition hover:bg-indigo-500/5',
|
|
|
+ idx % 2 === 0 ? 'bg-slate-900/30' : 'bg-slate-800/20',
|
|
|
+ ].join(' ')}
|
|
|
+ data-testid="doc-row"
|
|
|
+ >
|
|
|
+ <td className="px-4 py-3 font-mono text-slate-300 text-xs truncate max-w-xs">
|
|
|
+ {id}
|
|
|
+ </td>
|
|
|
+ <td className="px-4 py-3 font-mono text-slate-400 text-xs truncate max-w-md">
|
|
|
+ {compactPreview(doc)}
|
|
|
+ </td>
|
|
|
+ <td className="px-4 py-3">
|
|
|
+ <div className="flex items-center justify-end gap-0.5">
|
|
|
+ <button
|
|
|
+ onClick={(e) => {
|
|
|
+ e.stopPropagation()
|
|
|
+ onCopy(id, 'Document ID')
|
|
|
+ }}
|
|
|
+ className="p-1.5 rounded-md text-slate-500 hover:text-slate-200 hover:bg-slate-700/50 transition"
|
|
|
+ aria-label="Copy document ID"
|
|
|
+ title="Copy ID"
|
|
|
+ >
|
|
|
+ <Copy size={13} />
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={(e) => {
|
|
|
+ e.stopPropagation()
|
|
|
+ onCopy(JSON.stringify(doc, null, 2), 'Document JSON')
|
|
|
+ }}
|
|
|
+ className="p-1.5 rounded-md text-slate-500 hover:text-slate-200 hover:bg-slate-700/50 transition"
|
|
|
+ aria-label="Copy document JSON"
|
|
|
+ title="Copy JSON"
|
|
|
+ >
|
|
|
+ <Database size={13} />
|
|
|
+ </button>
|
|
|
+ <span className="p-1.5 text-slate-500" title="Click row to edit">
|
|
|
+ <Pencil size={13} />
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ )
|
|
|
+ })}
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
@@ -346,8 +406,11 @@ export default function Documents() {
|
|
|
const push = useToastStore((s) => s.push)
|
|
|
const queryClient = useQueryClient()
|
|
|
|
|
|
- // Collection selection
|
|
|
- const [selectedColl, setSelectedColl] = useState('')
|
|
|
+ // Collection selection — driven by the `?collection=` query param so the
|
|
|
+ // selection is shareable/bookmarkable and links from the Collections page
|
|
|
+ // land directly on the right collection.
|
|
|
+ const [searchParams, setSearchParams] = useSearchParams()
|
|
|
+ const selectedColl = searchParams.get('collection') ?? ''
|
|
|
|
|
|
// Filter state
|
|
|
const [filterField, setFilterField] = useState('')
|
|
|
@@ -356,6 +419,10 @@ export default function Documents() {
|
|
|
const [limit, setLimit] = useState('50')
|
|
|
const [offset, setOffset] = useState('0')
|
|
|
|
|
|
+ // Sort state (applied server-side via `sort`/`desc`)
|
|
|
+ const [sortField, setSortField] = useState('')
|
|
|
+ const [sortDesc, setSortDesc] = useState(false)
|
|
|
+
|
|
|
// The query string used for the last triggered search
|
|
|
const [appliedQs, setAppliedQs] = useState('')
|
|
|
|
|
|
@@ -365,7 +432,7 @@ export default function Documents() {
|
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
|
|
// Auto-select first collection on project change
|
|
|
- const { data: collectionsData } = useQuery<{ collections: CollectionMeta[] }>({
|
|
|
+ const { data: collectionsData, isLoading: collectionsLoading } = useQuery<{ collections: CollectionMeta[] }>({
|
|
|
queryKey: ['collections', currentProject],
|
|
|
queryFn: () => api.listCollections(currentProject!),
|
|
|
enabled: !!currentProject,
|
|
|
@@ -381,6 +448,8 @@ export default function Documents() {
|
|
|
isLoading: findLoading,
|
|
|
isError: findError,
|
|
|
error: findErrorObj,
|
|
|
+ refetch: refetchDocs,
|
|
|
+ isFetching: docsFetching,
|
|
|
} = useQuery({
|
|
|
queryKey: findQueryKey,
|
|
|
queryFn: () => api.findDocuments(currentProject!, effectiveCollection, appliedQs),
|
|
|
@@ -390,18 +459,50 @@ export default function Documents() {
|
|
|
const docs = findData?.documents ?? []
|
|
|
const docCount = findData?.count ?? null
|
|
|
|
|
|
+ // Pagination — the API returns a page of results (no grand total), so we use
|
|
|
+ // the full-page heuristic: a full page means there is probably a next page.
|
|
|
+ const limitNum = parseInt(limit, 10) || 50
|
|
|
+ const offsetNum = parseInt(offset, 10) || 0
|
|
|
+ const canPrev = offsetNum > 0
|
|
|
+ const canNext = docs.length >= limitNum
|
|
|
+
|
|
|
const invalidateDocs = () => {
|
|
|
void queryClient.invalidateQueries({ queryKey: ['documents', currentProject, effectiveCollection] })
|
|
|
}
|
|
|
|
|
|
- const handleSearch = () => {
|
|
|
- const qs = buildQueryString(filterField, filterOp, filterValue, limit, offset)
|
|
|
- setAppliedQs(qs)
|
|
|
+ // Single entry point for (re)issuing the server-side query. Optional overrides
|
|
|
+ // let pagination set a new offset and the sort controls change ordering; a sort
|
|
|
+ // change resets to the first page.
|
|
|
+ const applySearch = (opts: { offset?: string; sortField?: string; sortDesc?: boolean } = {}) => {
|
|
|
+ const sortChanged = opts.sortField !== undefined || opts.sortDesc !== undefined
|
|
|
+ const off = opts.offset ?? (sortChanged ? '0' : offset)
|
|
|
+ const sf = opts.sortField ?? sortField
|
|
|
+ const sd = opts.sortDesc ?? sortDesc
|
|
|
+ setOffset(off)
|
|
|
+ if (opts.sortField !== undefined) setSortField(opts.sortField)
|
|
|
+ if (opts.sortDesc !== undefined) setSortDesc(opts.sortDesc)
|
|
|
+ setAppliedQs(buildQueryString(filterField, filterOp, filterValue, limit, off, sf, sd))
|
|
|
+ }
|
|
|
+
|
|
|
+ // Toggle server-side sort by `id` from the ID column header.
|
|
|
+ const toggleSortById = () => {
|
|
|
+ if (sortField === 'id') applySearch({ sortDesc: !sortDesc })
|
|
|
+ else applySearch({ sortField: 'id', sortDesc: false })
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleCopy = async (text: string, label: string) => {
|
|
|
+ try {
|
|
|
+ await navigator.clipboard.writeText(text)
|
|
|
+ push(`${label} copied to clipboard.`, 'success')
|
|
|
+ } catch {
|
|
|
+ push('Copy failed — clipboard unavailable.', 'error')
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
const handleCollectionSelect = (coll: string) => {
|
|
|
- setSelectedColl(coll)
|
|
|
+ setSearchParams(coll ? { collection: coll } : {}, { replace: true })
|
|
|
setAppliedQs('')
|
|
|
+ setOffset('0')
|
|
|
}
|
|
|
|
|
|
// Open existing doc for editing
|
|
|
@@ -450,12 +551,20 @@ export default function Documents() {
|
|
|
try {
|
|
|
await api.deleteDocument(currentProject, effectiveCollection, modalState.docId)
|
|
|
push('Document deleted.', 'success')
|
|
|
- setModalOpen(false)
|
|
|
- invalidateDocs()
|
|
|
} catch (err) {
|
|
|
- const msg = err instanceof ApiError ? err.message : String(err)
|
|
|
- push(`Delete failed: ${msg}`, 'error')
|
|
|
+ // A 404 means the document is already gone (e.g. removed via the API or an
|
|
|
+ // ingestion job since the list loaded). The desired end-state is reached,
|
|
|
+ // so treat it as success and just refresh the now-stale list.
|
|
|
+ if (err instanceof ApiError && err.status === 404) {
|
|
|
+ push('Document was already removed; refreshed the list.', 'info')
|
|
|
+ } else {
|
|
|
+ const msg = err instanceof ApiError ? err.message : String(err)
|
|
|
+ push(`Delete failed: ${msg}`, 'error')
|
|
|
+ return // real failure — keep the editor open
|
|
|
+ }
|
|
|
}
|
|
|
+ setModalOpen(false)
|
|
|
+ invalidateDocs()
|
|
|
}
|
|
|
|
|
|
// ─── guard: no project selected ───────────────────────────────────────────
|
|
|
@@ -476,6 +585,31 @@ export default function Documents() {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
+ // ─── guard: project has no collections yet ────────────────────────────────
|
|
|
+
|
|
|
+ if (!collectionsLoading && collections.length === 0) {
|
|
|
+ return (
|
|
|
+ <div className="flex flex-col gap-6">
|
|
|
+ <div>
|
|
|
+ <h1 className="text-2xl font-semibold text-slate-100">Documents</h1>
|
|
|
+ <p className="text-slate-400 text-sm mt-1">
|
|
|
+ Project: <span className="text-slate-300 font-mono">{currentProject}</span>
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-12 text-center flex flex-col items-center gap-4">
|
|
|
+ <p className="text-slate-400 text-sm">This project has no collections yet.</p>
|
|
|
+ <Link
|
|
|
+ to="/collections"
|
|
|
+ className="inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition"
|
|
|
+ >
|
|
|
+ <Database size={16} />
|
|
|
+ Create your first collection
|
|
|
+ </Link>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
// ─── render ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
return (
|
|
|
@@ -483,28 +617,50 @@ export default function Documents() {
|
|
|
{/* Page header */}
|
|
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
|
<div>
|
|
|
+ {/* Breadcrumb */}
|
|
|
+ <nav className="flex items-center gap-1.5 text-xs text-slate-500 mb-1">
|
|
|
+ <Link to="/collections" className="inline-flex items-center gap-1 hover:text-indigo-300 transition">
|
|
|
+ <Database size={12} />
|
|
|
+ Collections
|
|
|
+ </Link>
|
|
|
+ <ChevronRight size={12} className="text-slate-600" />
|
|
|
+ <span className="font-mono text-slate-400">{effectiveCollection || '—'}</span>
|
|
|
+ </nav>
|
|
|
<h1 className="text-2xl font-semibold text-slate-100">Documents</h1>
|
|
|
<p className="text-slate-400 text-sm mt-1">
|
|
|
Project: <span className="text-slate-300 font-mono">{currentProject}</span>
|
|
|
{docCount !== null && (
|
|
|
<span className="ml-2 text-slate-500">
|
|
|
- — {docCount.toLocaleString()} result{docCount !== 1 ? 's' : ''}
|
|
|
+ — showing {docCount.toLocaleString()}
|
|
|
+ {(canNext || offsetNum > 0) && ` (rows ${(offsetNum + 1).toLocaleString()}–${(offsetNum + docs.length).toLocaleString()})`}
|
|
|
</span>
|
|
|
)}
|
|
|
</p>
|
|
|
</div>
|
|
|
- <button
|
|
|
- onClick={() => {
|
|
|
- setModalState({ mode: 'new', docId: '', json: '{}' })
|
|
|
- setModalOpen(true)
|
|
|
- }}
|
|
|
- disabled={!effectiveCollection}
|
|
|
- className="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50"
|
|
|
- data-testid="new-doc-btn"
|
|
|
- >
|
|
|
- <Plus size={16} />
|
|
|
- New document
|
|
|
- </button>
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <button
|
|
|
+ onClick={() => void refetchDocs()}
|
|
|
+ disabled={!effectiveCollection || docsFetching}
|
|
|
+ className="inline-flex items-center gap-1.5 px-3 py-2 rounded-md text-sm font-medium text-slate-300 bg-slate-700/50 hover:bg-slate-700 transition disabled:opacity-50"
|
|
|
+ data-testid="docs-refresh-btn"
|
|
|
+ title="Refresh documents"
|
|
|
+ >
|
|
|
+ <RefreshCw size={15} className={docsFetching ? 'animate-spin' : ''} />
|
|
|
+ Refresh
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ setModalState({ mode: 'new', docId: '', json: '{}' })
|
|
|
+ setModalOpen(true)
|
|
|
+ }}
|
|
|
+ disabled={!effectiveCollection}
|
|
|
+ className="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50"
|
|
|
+ data-testid="new-doc-btn"
|
|
|
+ >
|
|
|
+ <Plus size={16} />
|
|
|
+ New document
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
|
|
|
{/* Controls row */}
|
|
|
@@ -525,8 +681,32 @@ export default function Documents() {
|
|
|
onValueChange={setFilterValue}
|
|
|
onLimitChange={setLimit}
|
|
|
onOffsetChange={setOffset}
|
|
|
- onSearch={handleSearch}
|
|
|
+ onSearch={() => applySearch()}
|
|
|
/>
|
|
|
+ {/* Sort row — ordering is applied server-side */}
|
|
|
+ <div className="flex flex-wrap items-center gap-2">
|
|
|
+ <label className="text-xs font-medium text-slate-400 shrink-0">Sort by</label>
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ placeholder="field (e.g. id, name)"
|
|
|
+ value={sortField}
|
|
|
+ onChange={(e) => setSortField(e.target.value)}
|
|
|
+ onKeyDown={(e) => { if (e.key === 'Enter') applySearch() }}
|
|
|
+ className="rounded-md bg-slate-800 border border-slate-600 text-slate-200 text-sm px-3 py-1.5 w-44 placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40"
|
|
|
+ data-testid="doc-sort-field"
|
|
|
+ />
|
|
|
+ <button
|
|
|
+ onClick={() => applySearch({ sortDesc: !sortDesc })}
|
|
|
+ disabled={!sortField.trim()}
|
|
|
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium text-slate-300 bg-slate-700/50 hover:bg-slate-700 transition disabled:opacity-40"
|
|
|
+ data-testid="doc-sort-dir"
|
|
|
+ title="Toggle sort direction"
|
|
|
+ >
|
|
|
+ {sortDesc ? <ArrowDown size={14} /> : <ArrowUp size={14} />}
|
|
|
+ {sortDesc ? 'Desc' : 'Asc'}
|
|
|
+ </button>
|
|
|
+ <span className="text-[11px] text-slate-600">applied server-side</span>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
|
|
|
{/* Results */}
|
|
|
@@ -546,7 +726,43 @@ export default function Documents() {
|
|
|
)}
|
|
|
|
|
|
{!findLoading && !findError && (
|
|
|
- <ResultsTable docs={docs} onRowClick={(doc) => void handleRowClick(doc)} />
|
|
|
+ <ResultsTable
|
|
|
+ docs={docs}
|
|
|
+ onRowClick={(doc) => void handleRowClick(doc)}
|
|
|
+ onCopy={(text, label) => void handleCopy(text, label)}
|
|
|
+ sortField={sortField}
|
|
|
+ sortDesc={sortDesc}
|
|
|
+ onSortById={toggleSortById}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Pagination */}
|
|
|
+ {!findLoading && !findError && (canPrev || canNext) && (
|
|
|
+ <div className="flex items-center justify-between text-sm">
|
|
|
+ <span className="text-slate-500 text-xs">
|
|
|
+ Page {Math.floor(offsetNum / limitNum) + 1}
|
|
|
+ </span>
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <button
|
|
|
+ onClick={() => applySearch({ offset: String(Math.max(0, offsetNum - limitNum)) })}
|
|
|
+ disabled={!canPrev}
|
|
|
+ className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium text-slate-300 bg-slate-700/50 hover:bg-slate-700 transition disabled:opacity-40 disabled:cursor-not-allowed"
|
|
|
+ data-testid="docs-prev-btn"
|
|
|
+ >
|
|
|
+ <ChevronLeft size={14} />
|
|
|
+ Prev
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => applySearch({ offset: String(offsetNum + limitNum) })}
|
|
|
+ disabled={!canNext}
|
|
|
+ className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium text-slate-300 bg-slate-700/50 hover:bg-slate-700 transition disabled:opacity-40 disabled:cursor-not-allowed"
|
|
|
+ data-testid="docs-next-btn"
|
|
|
+ >
|
|
|
+ Next
|
|
|
+ <ChevronRight size={14} />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
)}
|
|
|
|
|
|
{/* Editor modal */}
|