+
setCreateOpen(true)}>
+
+ New tag
+
+ }
/>
+
+ {tags.length === 0 ? (
+
+
+
No tags yet
+
Create your first tag to start organising links.
+
+
+ ) : (
+
+ {tags.map((tag) => (
+
+
+
+ {tag.name}
+
+
+ {tag.urlCount ?? 0}
+
+
+
+
+ ))}
+
+ )}
+
+ {/* Create Dialog */}
+
+
+ {/* Edit Dialog */}
+
+
+ {/* Delete Dialog */}
+
)
}
diff --git a/apps/dashboard/src/pages/UrlsListPage.tsx b/apps/dashboard/src/pages/UrlsListPage.tsx
index 3c9bd98..2115d48 100644
--- a/apps/dashboard/src/pages/UrlsListPage.tsx
+++ b/apps/dashboard/src/pages/UrlsListPage.tsx
@@ -1,12 +1,17 @@
import { useState, useEffect, useCallback, useRef } from "react"
import { useAuth } from "@/hooks/use-auth"
-import { listUrls, listTags, listCollections, softDeleteUrl } from "@/lib/api"
-import type { ShortUrl, Tag, Collection, Pagination as PaginatedInfo } from "@linkify/shared"
+import {
+ listUrls, listTags, listCollections, softDeleteUrl,
+ bulkOperations, importCsv, bulkTagUrls, getSubscription,
+} from "@/lib/api"
+import type { ShortUrl, Tag, Collection, Pagination as PaginatedInfo, BulkResult } from "@linkify/shared"
import { toast } from "sonner"
import { useSearchParams, Link, useNavigate } from "react-router-dom"
import PageHeader from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Tooltip } from "@/components/ui/tooltip"
@@ -28,6 +33,15 @@ import {
Trash2,
Settings,
MoreHorizontal,
+ Tag as TagIcon,
+ Tags,
+ Folder,
+ CalendarDays,
+ FileSpreadsheet,
+ Loader2,
+ Upload,
+ CheckCircle2,
+ XCircle,
} from "lucide-react"
import {
DropdownMenu,
@@ -90,9 +104,38 @@ export default function UrlsListPage() {
const [selected, setSelected] = useState
>(new Set())
const [deleteDialog, setDeleteDialog] = useState(null)
const [deleting, setDeleting] = useState(false)
+ const [hasBulkOps, setHasBulkOps] = useState(false)
+
+ // Bulk tag dialog
+ const [bulkTagOpen, setBulkTagOpen] = useState(false)
+ const [bulkTagIds, setBulkTagIds] = useState([])
+ const [bulkTagging, setBulkTagging] = useState(false)
+
+ // Bulk move dialog
+ const [bulkMoveOpen, setBulkMoveOpen] = useState(false)
+ const [bulkMoveCollectionId, setBulkMoveCollectionId] = useState("")
+ const [bulkMoving, setBulkMoving] = useState(false)
+
+ // Bulk extend dialog
+ const [bulkExtendOpen, setBulkExtendOpen] = useState(false)
+ const [bulkExtendDays, setBulkExtendDays] = useState(30)
+ const [bulkExtending, setBulkExtending] = useState(false)
+
+ // Bulk delete dialog
+ const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false)
+ const [bulkDeleting, setBulkDeleting] = useState(false)
+
+ // CSV import
+ const [csvOpen, setCsvOpen] = useState(false)
+ const [csvText, setCsvText] = useState("")
+ const [csvCollectionId, setCsvCollectionId] = useState("")
+ const [csvResults, setCsvResults] = useState(null)
+ const [csvImporting, setCsvImporting] = useState(false)
const totalActiveFilters = [filters.tagIds, filters.collectionId, filters.createdAfter, filters.createdBefore, filters.hasPassword, filters.isActive].filter(Boolean).length
+ const abortRef = useRef(null)
+
async function fetchAllPages(
fetcher: (page: number) => Promise<{ items: T[]; pagination: PaginatedInfo }>,
): Promise {
@@ -106,8 +149,6 @@ export default function UrlsListPage() {
return all
}
- const abortRef = useRef(null)
-
const fetchData = useCallback(async () => {
if (!token) return
abortRef.current?.abort()
@@ -119,16 +160,18 @@ export default function UrlsListPage() {
for (const [key, val] of Object.entries(filters)) {
if (val) params.set(key, val)
}
- const [urlsData, tagsData, collectionsData] = await Promise.all([
+ const [urlsData, tagsData, collectionsData, subData] = await Promise.all([
listUrls(token, Object.fromEntries(params)),
fetchAllPages((page) => listTags(token, { page }).then((d) => ({ items: d.tags, pagination: d.pagination }))).catch(() => [] as Tag[]),
fetchAllPages((page) => listCollections(token, { page }).then((d) => ({ items: d.collections, pagination: d.pagination }))).catch(() => [] as Collection[]),
+ getSubscription(token).catch(() => null),
])
if (!controller.signal.aborted) {
setUrls(urlsData.urls)
setPagination(urlsData.pagination)
setTags(tagsData)
setCollections(collectionsData)
+ setHasBulkOps(subData?.plan?.features?.bulkOperations ?? false)
}
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") return
@@ -203,6 +246,115 @@ export default function UrlsListPage() {
}
}
+ // Bulk operations
+ const handleBulkTag = async () => {
+ if (bulkTagIds.length === 0) { toast.error("Select at least one tag"); return }
+ setBulkTagging(true)
+ try {
+ const results = await bulkTagUrls(token, Array.from(selected), bulkTagIds)
+ const ok = results.filter((r) => r.success).length
+ toast.success(`${ok} of ${results.length} tagged successfully`)
+ setBulkTagOpen(false)
+ setBulkTagIds([])
+ setSelected(new Set())
+ fetchData()
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : "Failed to tag URLs"
+ toast.error(msg)
+ } finally {
+ setBulkTagging(false)
+ }
+ }
+
+ const handleBulkMove = async () => {
+ if (bulkMoveCollectionId === "") { toast.error("Select a collection"); return }
+ setBulkMoving(true)
+ try {
+ const results = await bulkOperations(token, {
+ operation: "move",
+ codes: Array.from(selected),
+ collectionId: Number(bulkMoveCollectionId),
+ })
+ const ok = results.filter((r) => r.success).length
+ toast.success(`${ok} of ${results.length} moved successfully`)
+ setBulkMoveOpen(false)
+ setBulkMoveCollectionId("")
+ setSelected(new Set())
+ fetchData()
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : "Failed to move URLs"
+ toast.error(msg)
+ } finally {
+ setBulkMoving(false)
+ }
+ }
+
+ const handleBulkExtend = async () => {
+ if (bulkExtendDays < 1 || bulkExtendDays > 365) { toast.error("Days must be between 1 and 365"); return }
+ setBulkExtending(true)
+ try {
+ const results = await bulkOperations(token, {
+ operation: "extend",
+ codes: Array.from(selected),
+ extendDays: bulkExtendDays,
+ })
+ const ok = results.filter((r) => r.success).length
+ toast.success(`${ok} of ${results.length} extended successfully`)
+ setBulkExtendOpen(false)
+ setSelected(new Set())
+ fetchData()
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : "Failed to extend URLs"
+ toast.error(msg)
+ } finally {
+ setBulkExtending(false)
+ }
+ }
+
+ const handleBulkDelete = async () => {
+ setBulkDeleting(true)
+ try {
+ const results = await bulkOperations(token, {
+ operation: "delete",
+ codes: Array.from(selected),
+ })
+ const ok = results.filter((r) => r.success).length
+ toast.success(`${ok} of ${results.length} deleted successfully`)
+ setBulkDeleteOpen(false)
+ setSelected(new Set())
+ fetchData()
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : "Failed to delete URLs"
+ toast.error(msg)
+ } finally {
+ setBulkDeleting(false)
+ }
+ }
+
+ // CSV import
+ const handleCsvImport = async () => {
+ if (!csvText.trim()) { toast.error("Paste CSV content first"); return }
+ setCsvImporting(true)
+ setCsvResults(null)
+ try {
+ const results = await importCsv(
+ token,
+ csvText.trim(),
+ csvCollectionId !== "" ? Number(csvCollectionId) : undefined,
+ )
+ setCsvResults(results)
+ const ok = results.filter((r) => r.success).length
+ toast.success(`${ok} of ${results.length} URLs created successfully`)
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : "Failed to import CSV"
+ toast.error(msg)
+ } finally {
+ setCsvImporting(false)
+ }
+ }
+
+ const selectedArray = Array.from(selected)
+
const SortIcon = ({ column }: { column: string }) => {
if (filters.sortBy !== column) return
return filters.sortOrder === "asc" ? (
@@ -213,12 +365,16 @@ export default function UrlsListPage() {
}
return (
-
+
+
@@ -508,7 +664,49 @@ export default function UrlsListPage() {
)}
- {/* Delete Dialog */}
+ {/* Bulk Action Toolbar */}
+ {selected.size > 0 && (
+
+
+
+
+ {selected.size} selected
+
+
+
+ {hasBulkOps ? (
+
+
+
+
+
+
+ ) : (
+
+ Pro
+
+ Bulk operations require Pro plan
+
+
+
+
+
+ )}
+
+
+ )}
+
+ {/* Single Delete Dialog */}
+
+ {/* Bulk Tag Dialog */}
+
+
+ {/* Bulk Move Dialog */}
+
+
+ {/* Bulk Extend Dialog */}
+
+
+ {/* Bulk Delete Dialog */}
+
+
+ {/* CSV Import Dialog */}
+
)
}