From 3ed59bd480093ccf06fa43986d2ac21c45631813 Mon Sep 17 00:00:00 2001 From: Nehan Ahmed Date: Thu, 16 Jul 2026 11:46:54 +0500 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20phases=207-9=20=E2=80=94=20tags,=20?= =?UTF-8?q?bulk=20operations,=20CSV=20import,=20and=20billing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/src/components/color-picker.tsx | 70 +++ apps/dashboard/src/components/ui/progress.tsx | 27 ++ apps/dashboard/src/pages/BillingPage.tsx | 290 +++++++++++- apps/dashboard/src/pages/PlansPage.tsx | 188 +++++++- apps/dashboard/src/pages/TagDetailPage.tsx | 225 ++++++++- apps/dashboard/src/pages/TagsListPage.tsx | 264 ++++++++++- apps/dashboard/src/pages/UrlsListPage.tsx | 445 +++++++++++++++++- 7 files changed, 1486 insertions(+), 23 deletions(-) create mode 100644 apps/dashboard/src/components/color-picker.tsx create mode 100644 apps/dashboard/src/components/ui/progress.tsx diff --git a/apps/dashboard/src/components/color-picker.tsx b/apps/dashboard/src/components/color-picker.tsx new file mode 100644 index 0000000..c1138e4 --- /dev/null +++ b/apps/dashboard/src/components/color-picker.tsx @@ -0,0 +1,70 @@ +import { useState } from "react" +import { cn } from "@/lib/utils" +import { Input } from "@/components/ui/input" + +const PRESET_COLORS = [ + "#6366f1", + "#ef4444", + "#22c55e", + "#3b82f6", + "#f97316", + "#a855f7", + "#ec4899", + "#14b8a6", + "#eab308", + "#06b6d4", + "#f43f5e", + "#f59e0b", +] + +interface ColorPickerProps { + value: string + onChange: (color: string) => void +} + +export default function ColorPicker({ value, onChange }: ColorPickerProps) { + const [customHex, setCustomHex] = useState("") + + return ( +
+
+ {PRESET_COLORS.map((color) => ( +
+
+ Custom: + { + setCustomHex(e.target.value) + if (/^#[0-9a-fA-F]{6}$/.test(e.target.value)) { + onChange(e.target.value) + } + }} + placeholder="#6366f1" + className="h-7 w-24 font-mono text-xs" + /> + {value && ( +
+ )} +
+
+ ) +} diff --git a/apps/dashboard/src/components/ui/progress.tsx b/apps/dashboard/src/components/ui/progress.tsx new file mode 100644 index 0000000..e151241 --- /dev/null +++ b/apps/dashboard/src/components/ui/progress.tsx @@ -0,0 +1,27 @@ +import { cn } from "@/lib/utils" + +interface ProgressProps { + value: number + max?: number + className?: string + indicatorClassName?: string +} + +export default function Progress({ value, max = 100, className, indicatorClassName }: ProgressProps) { + const pct = Math.min(Math.max((value / max) * 100, 0), 100) + + return ( +
+
+
+ ) +} diff --git a/apps/dashboard/src/pages/BillingPage.tsx b/apps/dashboard/src/pages/BillingPage.tsx index 52d3717..efbae7d 100644 --- a/apps/dashboard/src/pages/BillingPage.tsx +++ b/apps/dashboard/src/pages/BillingPage.tsx @@ -1,12 +1,298 @@ +import { useState, useEffect, useCallback } from "react" +import { useAuth } from "@/hooks/use-auth" +import { getSubscription, getUsage, getPortalUrl, cancelSubscription } from "@/lib/api" +import type { UserSubscription, Usage } from "@linkify/shared" +import { toast } from "sonner" +import { Link, useNavigate } from "react-router-dom" import PageHeader from "@/components/page-header" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { Dialog, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import Progress from "@/components/ui/progress" +import { format } from "date-fns" +import { + CreditCard, + ExternalLink, + Loader2, + AlertTriangle, + CheckCircle2, + XCircle, + ArrowRight, + Link as LinkIcon, + MousePointerClick, +} from "lucide-react" + +function usageColor(pct: number): string { + if (pct >= 80) return "bg-destructive" + if (pct >= 60) return "bg-yellow-500" + return "bg-primary" +} export default function BillingPage() { + const { session } = useAuth() + const token = session?.access_token ?? "" + const navigate = useNavigate() + + const [subscription, setSubscription] = useState(null) + const [usage, setUsage] = useState(null) + const [loading, setLoading] = useState(true) + + const [portalLoading, setPortalLoading] = useState(false) + const [cancelOpen, setCancelOpen] = useState(false) + const [cancelling, setCancelling] = useState(false) + + const fetchData = useCallback(async () => { + if (!token) return + try { + const [subData, usageData] = await Promise.all([ + getSubscription(token).catch(() => null), + getUsage(token).catch(() => null), + ]) + setSubscription(subData) + setUsage(usageData) + } catch { + toast.error("Failed to load billing data") + } finally { + setLoading(false) + } + }, [token]) + + useEffect(() => { fetchData() }, [fetchData]) + + const handlePortal = async () => { + setPortalLoading(true) + try { + const { url } = await getPortalUrl(token) + window.location.href = url + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to open billing portal" + toast.error(msg) + } finally { + setPortalLoading(false) + } + } + + const handleCancel = async () => { + if (!subscription) return + setCancelling(true) + try { + await cancelSubscription(token, subscription.subscription.id) + toast.success("Subscription will be cancelled at period end") + setCancelOpen(false) + fetchData() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to cancel subscription" + toast.error(msg) + } finally { + setCancelling(false) + } + } + + if (loading) { + return ( +
+ +
+ + +
+
+ ) + } + + const plan = subscription?.plan + const sub = subscription?.subscription + const maxLinks = plan?.maxLinks ?? usage?.plan?.maxLinks ?? 100 + const maxApi = plan?.apiRateLimit ?? usage?.plan?.apiRateLimit ?? 100 + const linksUsed = usage?.totalLinks ?? 0 + const totalVisits = usage?.totalVisits ?? 0 + const linksPct = maxLinks > 0 ? Math.round((linksUsed / maxLinks) * 100) : 0 + const apiPct = maxApi > 0 ? Math.round((totalVisits / maxApi) * 100) : 0 + return ( -
+
+ +
+ {/* Subscription Card */} + + + Current Plan + Your subscription details + + + {plan ? ( + <> +
+
+

{plan.planName}

+ + {plan.status} + +
+ +
+ +
+ {sub && ( + <> +
+ Period start + + {format(new Date(sub.currentPeriodStart), "MMM d, yyyy")} + +
+
+ Period end + + {format(new Date(sub.currentPeriodEnd), "MMM d, yyyy")} + +
+ + )} + {plan.cancelAtPeriodEnd && ( +
+ + Cancels at period end +
+ )} +
+ +
+ + + + + {!plan.cancelAtPeriodEnd && plan.planCode !== "free" && ( + + )} +
+ + ) : ( +
+ +
+

No active subscription

+

You are on the Free plan.

+
+ + + +
+ )} +
+
+ + {/* Usage Card */} + + + Usage + Resource usage for {usage?.quotaMonth ?? "this month"} + + +
+
+
+ + Links +
+ + {linksUsed.toLocaleString()} / {maxLinks.toLocaleString()} + +
+ + {maxLinks > 0 && ( +

+ {linksPct}% used + {linksPct >= 80 && " — consider upgrading"} +

+ )} +
+ +
+
+
+ + Visits / API requests +
+ + {totalVisits.toLocaleString()} / {maxApi.toLocaleString()} + +
+ + {maxApi > 0 && ( +

+ {apiPct}% used + {apiPct >= 80 && " — consider upgrading"} +

+ )} +
+ + {plan ? ( +
+ + Features: {Object.entries(plan.features) + .filter(([, v]) => v) + .map(([k]) => k.replace(/([A-Z])/g, " $1").trim()) + .join(", ") || "none"} +
+ ) : null} +
+
+
+ + {/* Cancel Dialog */} + setCancelOpen(false)}> + + Cancel subscription + + Your plan will remain active until the end of the current billing period + ({subscription?.subscription?.currentPeriodEnd + ? format(new Date(subscription.subscription.currentPeriodEnd), "MMMM d, yyyy") + : "the end of the period"}). + After that, you will be downgraded to the Free plan. + + + + + + +
) } diff --git a/apps/dashboard/src/pages/PlansPage.tsx b/apps/dashboard/src/pages/PlansPage.tsx index c888cc4..471cc71 100644 --- a/apps/dashboard/src/pages/PlansPage.tsx +++ b/apps/dashboard/src/pages/PlansPage.tsx @@ -1,12 +1,196 @@ +import { useState, useEffect, useCallback } from "react" +import { useAuth } from "@/hooks/use-auth" +import { listPlans, createCheckoutSession, getSubscription } from "@/lib/api" +import type { Plan, UserPlanInfo } from "@linkify/shared" +import { toast } from "sonner" +import { useNavigate } from "react-router-dom" import PageHeader from "@/components/page-header" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Badge } from "@/components/ui/badge" +import { Check, X as XIcon, Loader2 } from "lucide-react" + +const FEATURE_LABELS: Record = { + advancedStats: "Advanced analytics", + customDomains: "Custom domains", + passwordProtection: "Password protection", + bulkOperations: "Bulk operations", + apiAccess: "API access", + affiliateLinks: "Affiliate links", + prioritySupport: "Priority support", +} export default function PlansPage() { + const { session } = useAuth() + const token = session?.access_token ?? "" + const navigate = useNavigate() + + const [plans, setPlans] = useState([]) + const [currentPlan, setCurrentPlan] = useState(null) + const [yearly, setYearly] = useState(false) + const [loading, setLoading] = useState(true) + const [subscribing, setSubscribing] = useState(null) + + const fetchData = useCallback(async () => { + try { + const [plansData, subData] = await Promise.all([ + listPlans(), + token ? getSubscription(token).catch(() => null) : null, + ]) + setPlans(plansData.sort((a, b) => a.sortOrder - b.sortOrder)) + if (subData) setCurrentPlan(subData.plan) + } catch { + toast.error("Failed to load plans") + } finally { + setLoading(false) + } + }, [token]) + + useEffect(() => { fetchData() }, [fetchData]) + + const handleSubscribe = async (planCode: string) => { + if (!token) return + setSubscribing(planCode) + try { + const { url } = await createCheckoutSession(token, { planCode }) + window.location.href = url + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to start checkout" + toast.error(msg) + } finally { + setSubscribing(null) + } + } + + if (loading) { + return ( +
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ ) + } + return ( -
+
+ +
+ + Monthly + + + + Yearly + Save up to 17% + +
+ +
+ {plans.map((plan) => { + const price = yearly ? plan.priceYearly : plan.priceMonthly + const isCurrent = currentPlan?.planCode === plan.code + const isFree = plan.code === "free" + + return ( +
+ {isCurrent && ( + + Current plan + + )} + +
+

{plan.name}

+

{plan.description}

+
+ +
+ + ${price} + + + /{yearly ? "year" : "month"} + + {isFree && (free forever)} +
+ +
+ {Object.entries(FEATURE_LABELS).map(([key, label]) => { + const included = plan.features[key as keyof typeof plan.features] + return ( +
+ {included ? ( + + ) : ( + + )} + + {label} + +
+ ) + })} +
+ +
+ {isCurrent ? ( + + ) : isFree ? ( + + ) : ( + + )} +
+ +
+ {plan.maxLinks.toLocaleString()} link limit + {plan.maxCustomDomains > 0 && ` · ${plan.maxCustomDomains} custom domains`} + {plan.apiRateLimit > 0 && ` · ${plan.apiRateLimit.toLocaleString()} req/mo`} +
+
+ ) + })} +
) } diff --git a/apps/dashboard/src/pages/TagDetailPage.tsx b/apps/dashboard/src/pages/TagDetailPage.tsx index 395dabf..1d65516 100644 --- a/apps/dashboard/src/pages/TagDetailPage.tsx +++ b/apps/dashboard/src/pages/TagDetailPage.tsx @@ -1,12 +1,227 @@ +import { useState, useEffect, useCallback } from "react" +import { useAuth } from "@/hooks/use-auth" +import { getTagUrls, updateTag, deleteTag } from "@/lib/api" +import type { Tag, Pagination } from "@linkify/shared" +import { toast } from "sonner" +import { useParams, 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 { Skeleton } from "@/components/ui/skeleton" +import { Dialog, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import TablePagination from "@/components/ui/pagination" +import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table" +import ColorPicker from "@/components/color-picker" +import { listTags } from "@/lib/api" +import { ExternalLink, Pencil, Trash2, Loader2, ArrowLeft, Hash } from "lucide-react" export default function TagDetailPage() { + const { id } = useParams<{ id: string }>() + const { session } = useAuth() + const token = session?.access_token ?? "" + const navigate = useNavigate() + const tagId = Number(id) + + const [tag, setTag] = useState(null) + const [urlCodes, setUrlCodes] = useState([]) + const [pagination, setPagination] = useState(null) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(true) + + const [editOpen, setEditOpen] = useState(false) + const [editName, setEditName] = useState("") + const [editColor, setEditColor] = useState("") + const [editing, setEditing] = useState(false) + + const [deleteOpen, setDeleteOpen] = useState(false) + const [deleting, setDeleting] = useState(false) + + const fetchData = useCallback(async () => { + if (!token || !tagId) return + setLoading(true) + try { + const [urlsData, tagsData] = await Promise.all([ + getTagUrls(token, tagId, { page, limit: 10 }), + listTags(token, { page: 1, limit: 100 }), + ]) + const found = tagsData.tags.find((t) => t.id === tagId) + if (found) setTag(found) + setUrlCodes(urlsData.urlCodes) + setPagination(urlsData.pagination) + } catch { + toast.error("Failed to load tag details") + } finally { + setLoading(false) + } + }, [token, tagId, page]) + + useEffect(() => { if (tagId) fetchData() }, [fetchData, tagId]) + + const handleEdit = async () => { + if (!tag || !editName.trim()) { toast.error("Name is required"); return } + setEditing(true) + try { + await updateTag(token, tag.id, { name: editName.trim(), color: editColor }) + toast.success("Tag updated") + setEditOpen(false) + fetchData() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to update tag" + toast.error(msg) + } finally { + setEditing(false) + } + } + + const handleDelete = async () => { + if (!tag) return + setDeleting(true) + try { + await deleteTag(token, tag.id) + toast.success("Tag deleted") + navigate("/tags") + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to delete tag" + toast.error(msg) + } finally { + setDeleting(false) + } + } + + if (loading) { + return ( +
+ + +
+ ) + } + + if (!tag) { + return ( +
+ +

Tag not found

+ +
+ ) + } + return ( -
- +
+
+ Tags + / + {tag.name} +
+ +
+
+
+
+

{tag.name}

+

{pagination?.total ?? 0} URLs

+
+
+
+ + +
+
+ + {urlCodes.length === 0 ? ( +
+ +

No URLs with this tag

+

Tag some URLs to see them here.

+ +
+ ) : ( +
+ + + + Code + Destination + + + + {urlCodes.map((code) => ( + + + + {code} + + + + {code} + + + ))} + +
+ {pagination && ( +
+ + Page {pagination.page} of {pagination.totalPages} ({pagination.total} total) + + +
+ )} +
+ )} + + {/* Edit Dialog */} + setEditOpen(false)}> + + Edit tag + Update the tag name or colour. + +
+
+ + setEditName(e.target.value)} autoFocus /> +
+
+ + +
+
+ + + + +
+ + {/* Delete Dialog */} + setDeleteOpen(false)}> + + Delete tag + + Are you sure you want to delete {tag?.name}? + Tags will be removed from all URLs. + + + + + + +
) } diff --git a/apps/dashboard/src/pages/TagsListPage.tsx b/apps/dashboard/src/pages/TagsListPage.tsx index eb0ae97..d9dc76c 100644 --- a/apps/dashboard/src/pages/TagsListPage.tsx +++ b/apps/dashboard/src/pages/TagsListPage.tsx @@ -1,12 +1,272 @@ +import { useState, useEffect, useCallback } from "react" +import { useAuth } from "@/hooks/use-auth" +import { listTags, createTag, updateTag, deleteTag } from "@/lib/api" +import type { Tag } from "@linkify/shared" +import { toast } from "sonner" +import { Link } 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 { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" +import { Dialog, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import ColorPicker from "@/components/color-picker" +import { Tag as TagIcon, Plus, Pencil, Trash2, Loader2 } from "lucide-react" + +async function fetchAllTags(token: string): Promise { + const first = await listTags(token, { page: 1, limit: 100 }) + const all = [...first.tags] + const totalPages = first.pagination.totalPages + for (let p = 2; p <= totalPages; p++) { + const page = await listTags(token, { page: p, limit: 100 }) + all.push(...page.tags) + } + return all +} export default function TagsListPage() { + const { session } = useAuth() + const token = session?.access_token ?? "" + + const [tags, setTags] = useState([]) + const [loading, setLoading] = useState(true) + + const [createOpen, setCreateOpen] = useState(false) + const [createName, setCreateName] = useState("") + const [createColor, setCreateColor] = useState("#6366f1") + const [creating, setCreating] = useState(false) + + const [editTarget, setEditTarget] = useState(null) + const [editName, setEditName] = useState("") + const [editColor, setEditColor] = useState("") + const [editing, setEditing] = useState(false) + + const [deleteTarget, setDeleteTarget] = useState(null) + const [deleting, setDeleting] = useState(false) + + const fetchData = useCallback(async () => { + if (!token) return + try { + const all = await fetchAllTags(token) + setTags(all) + } catch { + toast.error("Failed to load tags") + } finally { + setLoading(false) + } + }, [token]) + + useEffect(() => { fetchData() }, [fetchData]) + + const handleCreate = async () => { + if (!createName.trim()) { toast.error("Name is required"); return } + setCreating(true) + try { + await createTag(token, { name: createName.trim(), color: createColor }) + toast.success("Tag created") + setCreateOpen(false) + setCreateName("") + setCreateColor("#6366f1") + fetchData() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to create tag" + toast.error(msg) + } finally { + setCreating(false) + } + } + + const handleEdit = async () => { + if (!editTarget) return + if (!editName.trim()) { toast.error("Name is required"); return } + setEditing(true) + try { + await updateTag(token, editTarget.id, { name: editName.trim(), color: editColor }) + toast.success("Tag updated") + setEditTarget(null) + fetchData() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to update tag" + toast.error(msg) + } finally { + setEditing(false) + } + } + + const handleDelete = async () => { + if (!deleteTarget) return + setDeleting(true) + try { + await deleteTag(token, deleteTarget.id) + toast.success("Tag deleted") + setDeleteTarget(null) + fetchData() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to delete tag" + toast.error(msg) + } finally { + setDeleting(false) + } + } + + if (loading) { + return ( +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) + } + return ( -
+
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 */} + { setCreateOpen(false); setCreateName(""); setCreateColor("#6366f1") }}> + + Create tag + Create a new tag to organise your links. + +
+
+ + setCreateName(e.target.value)} + placeholder="e.g. docs" + autoFocus + /> +
+
+ + +
+
+ + + + +
+ + {/* Edit Dialog */} + setEditTarget(null)}> + + Edit tag + Update the tag name or colour. + +
+
+ + setEditName(e.target.value)} + autoFocus + /> +
+
+ + +
+
+ + + + +
+ + {/* Delete Dialog */} + setDeleteTarget(null)}> + + Delete tag + + Are you sure you want to delete {deleteTarget?.name}? + Tags will be removed from all URLs. + + + + + + +
) } 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 */} setDeleteDialog(null)}> Delete URL @@ -521,15 +719,238 @@ export default function UrlsListPage() { - + + {/* Bulk Tag Dialog */} + { setBulkTagOpen(false); setBulkTagIds([]) }}> + + Tag {selected.size} URLs + Select tags to apply to all selected URLs. + +
+ {tags.length === 0 ? ( +

No tags yet. Create one first.

+ ) : ( + tags.map((tag) => ( +
+ + {/* Bulk Move Dialog */} + { setBulkMoveOpen(false); setBulkMoveCollectionId("") }}> + + Move {selected.size} URLs + Select a collection to move URLs into. + +
+ + +
+ + + + +
+ + {/* Bulk Extend Dialog */} + { setBulkExtendOpen(false); setBulkExtendDays(30) }}> + + Extend {selected.size} URLs + Extend the expiry date by a number of days. + +
+ +
+ {[7, 30, 60, 90, 365].map((d) => ( + + ))} +
+ setBulkExtendDays(Math.min(365, Math.max(1, Number(e.target.value))))} + className="mt-2" + /> +
+ + + + +
+ + {/* Bulk Delete Dialog */} + setBulkDeleteOpen(false)}> + + Delete {selected.size} URLs + + Are you sure you want to delete these {selected.size} URLs? They will no longer be accessible. + + + + + + + + + {/* CSV Import Dialog */} + { setCsvOpen(false); setCsvText(""); setCsvResults(null); setCsvCollectionId("") }} + > + + Import CSV + + Paste CSV content to create multiple URLs at once. +
+ Columns: url (required),{" "} + customCode,{" "} + ttlDays,{" "} + password,{" "} + activeAt,{" "} + tags +
+
+
+ {!csvResults ? ( + <> +
+ +