mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 14:21:15 +00:00
feat: fix channel scanning bugs, add package tags, and kickstarters tab
Bug fixes: - Fix channels not being scanned by paginating TDLib getChats (was only loading first batch, additional channels were unknown to TDLib) - Add per-channel getChat pre-load as safety net before scanning - Fix preview pictures not loading by checking previewData instead of previewMsgId for hasPreview flag - Prevent previewMsgId from being set when preview download fails Package Tags: - Add tags Text[] column to Package with migration backfilling from channel categories - Worker auto-inherits source channel category as initial tag - Tag filter dropdown and Tags column in STL Files table - Server actions for individual and bulk tag editing Kickstarters Tab: - New KickstarterHost, Kickstarter, and KickstarterPackage models - Full CRUD with delivery status, payment status, host management - Package linking (many-to-many with existing packages) - Sidebar entry with Gift icon - Table with search, filters, modal forms Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
187
src/app/(app)/kickstarters/_components/kickstarter-columns.tsx
Normal file
187
src/app/(app)/kickstarters/_components/kickstarter-columns.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { MoreHorizontal, Pencil, Trash2, ExternalLink } from "lucide-react";
|
||||
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export interface KickstarterRow {
|
||||
id: string;
|
||||
name: string;
|
||||
link: string | null;
|
||||
filesUrl: string | null;
|
||||
deliveryStatus: "NOT_DELIVERED" | "PARTIAL" | "DELIVERED";
|
||||
paymentStatus: "PAID" | "UNPAID";
|
||||
notes: string | null;
|
||||
hostId: string | null;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
host: { id: string; name: string } | null;
|
||||
_count: { packages: number };
|
||||
}
|
||||
|
||||
interface KickstarterColumnsProps {
|
||||
onEdit: (kickstarter: KickstarterRow) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
const deliveryConfig: Record<string, { label: string; className: string }> = {
|
||||
NOT_DELIVERED: {
|
||||
label: "Not Delivered",
|
||||
className: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
},
|
||||
PARTIAL: {
|
||||
label: "Partial",
|
||||
className: "bg-orange-500/15 text-orange-400 border-orange-500/30",
|
||||
},
|
||||
DELIVERED: {
|
||||
label: "Delivered",
|
||||
className: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
},
|
||||
};
|
||||
|
||||
const paymentConfig: Record<string, { label: string; className: string }> = {
|
||||
PAID: {
|
||||
label: "Paid",
|
||||
className: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
},
|
||||
UNPAID: {
|
||||
label: "Unpaid",
|
||||
className: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
},
|
||||
};
|
||||
|
||||
export function getKickstarterColumns({
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: KickstarterColumnsProps): ColumnDef<KickstarterRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
{row.original.link && (
|
||||
<a
|
||||
href={row.original.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:text-primary/80"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "host",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Host" />,
|
||||
cell: ({ row }) =>
|
||||
row.original.host ? (
|
||||
<span className="text-sm">{row.original.host.name}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">--</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "files",
|
||||
header: "Files",
|
||||
cell: ({ row }) =>
|
||||
row.original.filesUrl ? (
|
||||
<a
|
||||
href={row.original.filesUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground">--</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "deliveryStatus",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Delivery" />,
|
||||
cell: ({ row }) => {
|
||||
const config = deliveryConfig[row.original.deliveryStatus];
|
||||
return (
|
||||
<Badge variant="outline" className={`text-[10px] font-medium ${config.className}`}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "paymentStatus",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Payment" />,
|
||||
cell: ({ row }) => {
|
||||
const config = paymentConfig[row.original.paymentStatus];
|
||||
return (
|
||||
<Badge variant="outline" className={`text-[10px] font-medium ${config.className}`}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "packages",
|
||||
header: "Packages",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.original._count.packages}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Created" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(row.original.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(row.original.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
301
src/app/(app)/kickstarters/_components/kickstarter-form.tsx
Normal file
301
src/app/(app)/kickstarters/_components/kickstarter-form.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "sonner";
|
||||
import { Plus } from "lucide-react";
|
||||
import { kickstarterSchema, type KickstarterInput } from "@/schemas/kickstarter.schema";
|
||||
import { createKickstarter, updateKickstarter, createHost } from "../actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface HostOption {
|
||||
id: string;
|
||||
name: string;
|
||||
_count: { kickstarters: number };
|
||||
}
|
||||
|
||||
interface KickstarterFormProps {
|
||||
kickstarter?: {
|
||||
id: string;
|
||||
name: string;
|
||||
link: string | null;
|
||||
filesUrl: string | null;
|
||||
deliveryStatus: "NOT_DELIVERED" | "PARTIAL" | "DELIVERED";
|
||||
paymentStatus: "PAID" | "UNPAID";
|
||||
hostId: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
hosts: HostOption[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function KickstarterForm({ kickstarter, hosts, onSuccess }: KickstarterFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [hostList, setHostList] = useState(hosts);
|
||||
const [showNewHost, setShowNewHost] = useState(false);
|
||||
const [newHostName, setNewHostName] = useState("");
|
||||
const isEditing = !!kickstarter;
|
||||
|
||||
const form = useForm<KickstarterInput>({
|
||||
resolver: zodResolver(kickstarterSchema),
|
||||
defaultValues: {
|
||||
name: kickstarter?.name ?? "",
|
||||
link: kickstarter?.link ?? "",
|
||||
filesUrl: kickstarter?.filesUrl ?? "",
|
||||
deliveryStatus: kickstarter?.deliveryStatus ?? "NOT_DELIVERED",
|
||||
paymentStatus: kickstarter?.paymentStatus ?? "UNPAID",
|
||||
hostId: kickstarter?.hostId ?? "",
|
||||
notes: kickstarter?.notes ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: KickstarterInput) {
|
||||
startTransition(async () => {
|
||||
const result = isEditing
|
||||
? await updateKickstarter(kickstarter!.id, values)
|
||||
: await createKickstarter(values);
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(isEditing ? "Kickstarter updated" : "Kickstarter created");
|
||||
form.reset();
|
||||
onSuccess();
|
||||
});
|
||||
}
|
||||
|
||||
function handleAddHost() {
|
||||
if (!newHostName.trim()) return;
|
||||
startTransition(async () => {
|
||||
const result = await createHost({ name: newHostName.trim() });
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success(`Host "${result.data!.name}" created`);
|
||||
setHostList((prev) => [
|
||||
...prev,
|
||||
{ id: result.data!.id, name: result.data!.name, _count: { kickstarters: 0 } },
|
||||
]);
|
||||
form.setValue("hostId", result.data!.id);
|
||||
setNewHostName("");
|
||||
setShowNewHost(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Kickstarter name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="link"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Link</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://kickstarter.com/..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="filesUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Files URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://drive.google.com/..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="deliveryStatus"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Delivery Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="NOT_DELIVERED">Not Delivered</SelectItem>
|
||||
<SelectItem value="PARTIAL">Partial</SelectItem>
|
||||
<SelectItem value="DELIVERED">Delivered</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="paymentStatus"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Payment Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="PAID">Paid</SelectItem>
|
||||
<SelectItem value="UNPAID">Unpaid</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hostId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
{!showNewHost ? (
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
onValueChange={(v) => field.onChange(v === "none" ? "" : v)}
|
||||
defaultValue={field.value || "none"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select host (optional)" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No Host</SelectItem>
|
||||
{hostList.map((host) => (
|
||||
<SelectItem key={host.id} value={host.id}>
|
||||
{host.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowNewHost(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="New host name"
|
||||
value={newHostName}
|
||||
onChange={(e) => setNewHostName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAddHost();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setShowNewHost(false);
|
||||
setNewHostName("");
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddHost}
|
||||
disabled={isPending || !newHostName.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowNewHost(false);
|
||||
setNewHostName("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notes</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Optional notes" rows={3} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : isEditing ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
54
src/app/(app)/kickstarters/_components/kickstarter-modal.tsx
Normal file
54
src/app/(app)/kickstarters/_components/kickstarter-modal.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { KickstarterForm } from "./kickstarter-form";
|
||||
|
||||
interface HostOption {
|
||||
id: string;
|
||||
name: string;
|
||||
_count: { kickstarters: number };
|
||||
}
|
||||
|
||||
interface KickstarterModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
hosts: HostOption[];
|
||||
kickstarter?: {
|
||||
id: string;
|
||||
name: string;
|
||||
link: string | null;
|
||||
filesUrl: string | null;
|
||||
deliveryStatus: "NOT_DELIVERED" | "PARTIAL" | "DELIVERED";
|
||||
paymentStatus: "PAID" | "UNPAID";
|
||||
hostId: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function KickstarterModal({ open, onOpenChange, hosts, kickstarter }: KickstarterModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{kickstarter ? "Edit Kickstarter" : "Add Kickstarter"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{kickstarter
|
||||
? "Update the kickstarter details below."
|
||||
: "Track a new Kickstarter or crowdfunding campaign."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<KickstarterForm
|
||||
kickstarter={kickstarter}
|
||||
hosts={hosts}
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
193
src/app/(app)/kickstarters/_components/kickstarter-table.tsx
Normal file
193
src/app/(app)/kickstarters/_components/kickstarter-table.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useTransition } from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
import { getKickstarterColumns, type KickstarterRow } from "./kickstarter-columns";
|
||||
import { KickstarterModal } from "./kickstarter-modal";
|
||||
import { deleteKickstarter } from "../actions";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
import { DataTablePagination } from "@/components/shared/data-table-pagination";
|
||||
import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
|
||||
import { DeleteDialog } from "@/components/shared/delete-dialog";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface HostOption {
|
||||
id: string;
|
||||
name: string;
|
||||
_count: { kickstarters: number };
|
||||
}
|
||||
|
||||
interface KickstarterTableProps {
|
||||
data: KickstarterRow[];
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
hosts: HostOption[];
|
||||
}
|
||||
|
||||
export function KickstarterTable({
|
||||
data,
|
||||
pageCount,
|
||||
totalCount,
|
||||
hosts,
|
||||
}: KickstarterTableProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editKickstarter, setEditKickstarter] = useState<KickstarterRow | undefined>();
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? "");
|
||||
|
||||
const updateSearch = useCallback(
|
||||
(value: string) => {
|
||||
setSearchValue(value);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set("search", value);
|
||||
params.set("page", "1");
|
||||
} else {
|
||||
params.delete("search");
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[router, pathname, searchParams]
|
||||
);
|
||||
|
||||
const updateFilter = useCallback(
|
||||
(key: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value && value !== "all") {
|
||||
params.set(key, value);
|
||||
params.set("page", "1");
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[router, pathname, searchParams]
|
||||
);
|
||||
|
||||
const columns = getKickstarterColumns({
|
||||
onEdit: (kickstarter) => {
|
||||
setEditKickstarter(kickstarter);
|
||||
setModalOpen(true);
|
||||
},
|
||||
onDelete: (id) => setDeleteId(id),
|
||||
});
|
||||
|
||||
const { table } = useDataTable({ data, columns, pageCount });
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteId) return;
|
||||
startTransition(async () => {
|
||||
const result = await deleteKickstarter(deleteId);
|
||||
if (result.success) {
|
||||
toast.success("Kickstarter deleted");
|
||||
setDeleteId(null);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const activeDelivery = searchParams.get("delivery") ?? "";
|
||||
const activePayment = searchParams.get("payment") ?? "";
|
||||
const activeHost = searchParams.get("host") ?? "";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Kickstarters" description="Track your crowdfunding campaigns and deliveries">
|
||||
<Button onClick={() => { setEditKickstarter(undefined); setModalOpen(true); }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Kickstarter
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search kickstarters..."
|
||||
value={searchValue}
|
||||
onChange={(e) => updateSearch(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={activeDelivery || "all"} onValueChange={(v) => updateFilter("delivery", v)}>
|
||||
<SelectTrigger className="w-[160px] h-9">
|
||||
<SelectValue placeholder="All Delivery" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Delivery</SelectItem>
|
||||
<SelectItem value="NOT_DELIVERED">Not Delivered</SelectItem>
|
||||
<SelectItem value="PARTIAL">Partial</SelectItem>
|
||||
<SelectItem value="DELIVERED">Delivered</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={activePayment || "all"} onValueChange={(v) => updateFilter("payment", v)}>
|
||||
<SelectTrigger className="w-[140px] h-9">
|
||||
<SelectValue placeholder="All Payment" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Payment</SelectItem>
|
||||
<SelectItem value="PAID">Paid</SelectItem>
|
||||
<SelectItem value="UNPAID">Unpaid</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hosts.length > 0 && (
|
||||
<Select value={activeHost || "all"} onValueChange={(v) => updateFilter("host", v)}>
|
||||
<SelectTrigger className="w-[160px] h-9">
|
||||
<SelectValue placeholder="All Hosts" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Hosts</SelectItem>
|
||||
{hosts.map((host) => (
|
||||
<SelectItem key={host.id} value={host.id}>
|
||||
{host.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
|
||||
<DataTable table={table} emptyMessage="No kickstarters found. Add your first campaign!" />
|
||||
<DataTablePagination table={table} totalCount={totalCount} />
|
||||
|
||||
<KickstarterModal
|
||||
open={modalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setModalOpen(open);
|
||||
if (!open) setEditKickstarter(undefined);
|
||||
}}
|
||||
hosts={hosts}
|
||||
kickstarter={editKickstarter}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
open={!!deleteId}
|
||||
onOpenChange={(open) => !open && setDeleteId(null)}
|
||||
title="Delete Kickstarter"
|
||||
description="This will permanently delete this kickstarter and unlink any associated packages."
|
||||
onConfirm={handleDelete}
|
||||
isLoading={isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
src/app/(app)/kickstarters/actions.ts
Normal file
148
src/app/(app)/kickstarters/actions.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { kickstarterSchema, kickstarterHostSchema } from "@/schemas/kickstarter.schema";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { ActionResult } from "@/types/api.types";
|
||||
|
||||
const REVALIDATE_PATH = "/kickstarters";
|
||||
|
||||
export async function createKickstarter(
|
||||
input: unknown
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
const parsed = kickstarterSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "Validation failed" };
|
||||
|
||||
try {
|
||||
const ks = await prisma.kickstarter.create({
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
link: parsed.data.link || null,
|
||||
filesUrl: parsed.data.filesUrl || null,
|
||||
deliveryStatus: parsed.data.deliveryStatus,
|
||||
paymentStatus: parsed.data.paymentStatus,
|
||||
hostId: parsed.data.hostId || null,
|
||||
notes: parsed.data.notes || null,
|
||||
userId: session.user.id,
|
||||
},
|
||||
});
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: { id: ks.id } };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to create kickstarter" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateKickstarter(
|
||||
id: string,
|
||||
input: unknown
|
||||
): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
const parsed = kickstarterSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "Validation failed" };
|
||||
|
||||
const existing = await prisma.kickstarter.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
});
|
||||
if (!existing) return { success: false, error: "Not found" };
|
||||
|
||||
try {
|
||||
await prisma.kickstarter.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
link: parsed.data.link || null,
|
||||
filesUrl: parsed.data.filesUrl || null,
|
||||
deliveryStatus: parsed.data.deliveryStatus,
|
||||
paymentStatus: parsed.data.paymentStatus,
|
||||
hostId: parsed.data.hostId || null,
|
||||
notes: parsed.data.notes || null,
|
||||
},
|
||||
});
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to update kickstarter" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteKickstarter(id: string): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
const existing = await prisma.kickstarter.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
});
|
||||
if (!existing) return { success: false, error: "Not found" };
|
||||
|
||||
try {
|
||||
await prisma.kickstarter.delete({ where: { id } });
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to delete kickstarter" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createHost(
|
||||
input: unknown
|
||||
): Promise<ActionResult<{ id: string; name: string }>> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
const parsed = kickstarterHostSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "Validation failed" };
|
||||
|
||||
try {
|
||||
const host = await prisma.kickstarterHost.create({
|
||||
data: { name: parsed.data.name },
|
||||
});
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: { id: host.id, name: host.name } };
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message.includes("Unique constraint")
|
||||
) {
|
||||
return { success: false, error: "A host with that name already exists" };
|
||||
}
|
||||
return { success: false, error: "Failed to create host" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function linkPackages(
|
||||
kickstarterId: string,
|
||||
packageIds: string[]
|
||||
): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
const existing = await prisma.kickstarter.findFirst({
|
||||
where: { id: kickstarterId, userId: session.user.id },
|
||||
});
|
||||
if (!existing) return { success: false, error: "Not found" };
|
||||
|
||||
try {
|
||||
// Replace all linked packages
|
||||
await prisma.$transaction([
|
||||
prisma.kickstarterPackage.deleteMany({
|
||||
where: { kickstarterId },
|
||||
}),
|
||||
...packageIds.map((packageId) =>
|
||||
prisma.kickstarterPackage.create({
|
||||
data: { kickstarterId, packageId },
|
||||
})
|
||||
),
|
||||
]);
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to link packages" };
|
||||
}
|
||||
}
|
||||
29
src/app/(app)/kickstarters/page.tsx
Normal file
29
src/app/(app)/kickstarters/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getKickstarters, getKickstarterHosts } from "@/data/kickstarter.queries";
|
||||
import type { DataTableSearchParams } from "@/types/table.types";
|
||||
import { KickstarterTable } from "./_components/kickstarter-table";
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<DataTableSearchParams & { delivery?: string; payment?: string; host?: string }>;
|
||||
}
|
||||
|
||||
export default async function KickstartersPage({ searchParams }: Props) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const params = await searchParams;
|
||||
const [{ data, pageCount, totalCount }, hosts] = await Promise.all([
|
||||
getKickstarters(session.user.id, params),
|
||||
getKickstarterHosts(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<KickstarterTable
|
||||
data={data}
|
||||
pageCount={pageCount}
|
||||
totalCount={totalCount}
|
||||
hosts={hosts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { FileArchive, Eye, Pencil } from "lucide-react";
|
||||
import { FileArchive, Eye } from "lucide-react";
|
||||
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -17,6 +17,7 @@ export interface PackageRow {
|
||||
isMultipart: boolean;
|
||||
hasPreview: boolean;
|
||||
creator: string | null;
|
||||
tags: string[];
|
||||
indexedAt: string;
|
||||
sourceChannel: {
|
||||
id: string;
|
||||
@@ -27,6 +28,7 @@ export interface PackageRow {
|
||||
interface PackageColumnsProps {
|
||||
onViewFiles: (pkg: PackageRow) => void;
|
||||
onSetCreator: (pkg: PackageRow) => void;
|
||||
onSetTags: (pkg: PackageRow) => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytesStr: string): string {
|
||||
@@ -59,6 +61,7 @@ function PreviewCell({ pkg }: { pkg: PackageRow }) {
|
||||
export function getPackageColumns({
|
||||
onViewFiles,
|
||||
onSetCreator,
|
||||
onSetTags,
|
||||
}: PackageColumnsProps): ColumnDef<PackageRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
@@ -124,6 +127,42 @@ export function getPackageColumns({
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Tags" />,
|
||||
cell: ({ row }) => {
|
||||
const tags = row.original.tags;
|
||||
if (tags.length === 0) {
|
||||
return (
|
||||
<button
|
||||
className="text-sm text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={() => onSetTags(row.original)}
|
||||
title="Click to add tags"
|
||||
>
|
||||
{"\u2014"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className="flex flex-wrap gap-1 cursor-pointer"
|
||||
onClick={() => onSetTags(row.original)}
|
||||
title="Click to edit tags"
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="text-[10px] bg-primary/5"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
accessorFn: (row) => row.tags.join(", "),
|
||||
},
|
||||
{
|
||||
id: "channel",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Source" />,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useCallback, useTransition } from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Search, FileBox } from "lucide-react";
|
||||
import { Search } from "lucide-react";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
import { getPackageColumns, type PackageRow } from "./package-columns";
|
||||
import { PackageFilesDrawer } from "./package-files-drawer";
|
||||
@@ -13,14 +13,22 @@ import { DataTablePagination } from "@/components/shared/data-table-pagination";
|
||||
import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { IngestionAccountStatus } from "@/lib/telegram/types";
|
||||
import { updatePackageCreator } from "../actions";
|
||||
import { updatePackageCreator, updatePackageTags } from "../actions";
|
||||
|
||||
interface StlTableProps {
|
||||
data: PackageRow[];
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
ingestionStatus: IngestionAccountStatus[];
|
||||
availableTags: string[];
|
||||
}
|
||||
|
||||
export function StlTable({
|
||||
@@ -28,6 +36,7 @@ export function StlTable({
|
||||
pageCount,
|
||||
totalCount,
|
||||
ingestionStatus,
|
||||
availableTags,
|
||||
}: StlTableProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -52,6 +61,20 @@ export function StlTable({
|
||||
[router, pathname, searchParams]
|
||||
);
|
||||
|
||||
const updateTagFilter = useCallback(
|
||||
(value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value && value !== "all") {
|
||||
params.set("tag", value);
|
||||
params.set("page", "1");
|
||||
} else {
|
||||
params.delete("tag");
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[router, pathname, searchParams]
|
||||
);
|
||||
|
||||
const columns = getPackageColumns({
|
||||
onViewFiles: (pkg) => setViewPkg(pkg),
|
||||
onSetCreator: (pkg) => {
|
||||
@@ -67,10 +90,29 @@ export function StlTable({
|
||||
}
|
||||
});
|
||||
},
|
||||
onSetTags: (pkg) => {
|
||||
const value = prompt(
|
||||
"Enter tags (comma-separated):",
|
||||
pkg.tags.join(", ")
|
||||
);
|
||||
if (value === null) return;
|
||||
const tags = value.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
startTransition(async () => {
|
||||
const result = await updatePackageTags(pkg.id, tags);
|
||||
if (result.success) {
|
||||
toast.success(tags.length > 0 ? `Tags updated` : "Tags removed");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { table } = useDataTable({ data, columns, pageCount });
|
||||
|
||||
const activeTag = searchParams.get("tag") ?? "";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
@@ -90,6 +132,21 @@ export function StlTable({
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
{availableTags.length > 0 && (
|
||||
<Select value={activeTag || "all"} onValueChange={updateTagFilter}>
|
||||
<SelectTrigger className="w-[160px] h-9">
|
||||
<SelectValue placeholder="All Tags" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Tags</SelectItem>
|
||||
{availableTags.map((tag) => (
|
||||
<SelectItem key={tag} value={tag}>
|
||||
{tag}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -71,6 +71,48 @@ export async function uploadPackagePreview(
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePackageTags(
|
||||
packageId: string,
|
||||
tags: string[]
|
||||
): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
try {
|
||||
const cleaned = tags.map((t) => t.trim()).filter(Boolean);
|
||||
// Deduplicate
|
||||
const unique = [...new Set(cleaned)];
|
||||
await prisma.package.update({
|
||||
where: { id: packageId },
|
||||
data: { tags: unique },
|
||||
});
|
||||
revalidatePath("/stls");
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to update tags" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkSetTags(
|
||||
packageIds: string[],
|
||||
tags: string[]
|
||||
): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
try {
|
||||
const cleaned = [...new Set(tags.map((t) => t.trim()).filter(Boolean))];
|
||||
await prisma.package.updateMany({
|
||||
where: { id: { in: packageIds } },
|
||||
data: { tags: cleaned },
|
||||
});
|
||||
revalidatePath("/stls");
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to update tags" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkSetCreator(
|
||||
packageIds: string[],
|
||||
creator: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listPackages, searchPackages, getIngestionStatus } from "@/lib/telegram/queries";
|
||||
import { listPackages, searchPackages, getIngestionStatus, getAllPackageTags } from "@/lib/telegram/queries";
|
||||
import { StlTable } from "./_components/stl-table";
|
||||
|
||||
interface Props {
|
||||
@@ -19,9 +19,10 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
||||
const order = (params.order as "asc" | "desc") ?? "desc";
|
||||
const search = (params.search as string) ?? "";
|
||||
const creator = (params.creator as string) || undefined;
|
||||
const tag = (params.tag as string) || undefined;
|
||||
|
||||
// Fetch packages and ingestion status in parallel
|
||||
const [result, ingestionStatus] = await Promise.all([
|
||||
// Fetch packages, ingestion status, and available tags in parallel
|
||||
const [result, ingestionStatus, availableTags] = await Promise.all([
|
||||
search
|
||||
? searchPackages({
|
||||
query: search,
|
||||
@@ -33,10 +34,12 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
||||
page,
|
||||
limit: perPage,
|
||||
creator,
|
||||
tag,
|
||||
sortBy: sort as "indexedAt" | "fileName" | "fileSize",
|
||||
order,
|
||||
}),
|
||||
getIngestionStatus(),
|
||||
getAllPackageTags(),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -45,6 +48,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
||||
pageCount={result.pagination.totalPages}
|
||||
totalCount={result.pagination.total}
|
||||
ingestionStatus={ingestionStatus}
|
||||
availableTags={availableTags}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user