mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 06:11:15 +00:00
Compare commits
3 Commits
e2dd3bb9d0
...
aef76828ef
| Author | SHA1 | Date | |
|---|---|---|---|
| aef76828ef | |||
| 29e95f780c | |||
| 5fd341dfc4 |
@@ -115,9 +115,15 @@ export async function getPendingSendRequest(requestId: string) {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
fileName: true,
|
fileName: true,
|
||||||
|
fileSize: true,
|
||||||
|
fileCount: true,
|
||||||
|
creator: true,
|
||||||
|
tags: true,
|
||||||
|
archiveType: true,
|
||||||
destChannelId: true,
|
destChannelId: true,
|
||||||
destMessageId: true,
|
destMessageId: true,
|
||||||
previewData: true,
|
previewData: true,
|
||||||
|
sourceChannel: { select: { title: true, telegramId: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
telegramLink: true,
|
telegramLink: true,
|
||||||
|
|||||||
@@ -134,9 +134,22 @@ async function processSendRequest(requestId: string): Promise<void> {
|
|||||||
throw new Error("No global destination channel configured");
|
throw new Error("No global destination channel configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send preview if available
|
// Send preview with rich caption if available
|
||||||
if (pkg.previewData) {
|
if (pkg.previewData) {
|
||||||
const caption = `📦 *${pkg.fileName}*\n\nSent from Dragon's Stash`;
|
const lines: string[] = [];
|
||||||
|
lines.push(`📦 *${escapeMarkdown(pkg.fileName)}*`);
|
||||||
|
if (pkg.creator) lines.push(`👤 ${escapeMarkdown(pkg.creator)}`);
|
||||||
|
if (pkg.fileCount > 0) lines.push(`📁 ${pkg.fileCount} files`);
|
||||||
|
if (pkg.tags && pkg.tags.length > 0) {
|
||||||
|
lines.push(`🏷️ ${pkg.tags.map((t: string) => escapeMarkdown(t)).join(", ")}`);
|
||||||
|
}
|
||||||
|
if (pkg.sourceChannel) {
|
||||||
|
lines.push(`📡 Source: ${escapeMarkdown(pkg.sourceChannel.title)}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
lines.push("_Sent from Dragon's Stash_");
|
||||||
|
|
||||||
|
const caption = lines.join("\n");
|
||||||
await sendPhotoMessage(targetUserId, Buffer.from(pkg.previewData), caption);
|
await sendPhotoMessage(targetUserId, Buffer.from(pkg.previewData), caption);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +202,9 @@ async function handleNewPackage(payload: string): Promise<void> {
|
|||||||
`🔔 <b>New package matching your subscriptions:</b>`,
|
`🔔 <b>New package matching your subscriptions:</b>`,
|
||||||
``,
|
``,
|
||||||
`📦 <b>${escapeHtml(data.fileName)}</b>${creator}`,
|
`📦 <b>${escapeHtml(data.fileName)}</b>${creator}`,
|
||||||
|
...(data.tags && data.tags.length > 0
|
||||||
|
? [`🏷️ ${data.tags.map((t: string) => escapeHtml(t)).join(", ")}`]
|
||||||
|
: []),
|
||||||
``,
|
``,
|
||||||
`Matched: ${patterns.map((p) => `"${escapeHtml(p)}"`).join(", ")}`,
|
`Matched: ${patterns.map((p) => `"${escapeHtml(p)}"`).join(", ")}`,
|
||||||
``,
|
``,
|
||||||
@@ -213,3 +229,7 @@ async function handleNewPackage(payload: string): Promise<void> {
|
|||||||
function escapeHtml(text: string): string {
|
function escapeHtml(text: string): string {
|
||||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeMarkdown(text: string): string {
|
||||||
|
return text.replace(/([_*[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Add tags array column to packages
|
||||||
|
ALTER TABLE "packages" ADD COLUMN "tags" TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
-- Backfill: inherit source channel category as initial tag
|
||||||
|
UPDATE "packages" p
|
||||||
|
SET "tags" = ARRAY[c."category"]
|
||||||
|
FROM "telegram_channels" c
|
||||||
|
WHERE p."sourceChannelId" = c."id"
|
||||||
|
AND c."category" IS NOT NULL
|
||||||
|
AND c."category" != '';
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DeliveryStatus" AS ENUM ('NOT_DELIVERED', 'PARTIAL', 'DELIVERED');
|
||||||
|
CREATE TYPE "PaymentStatus" AS ENUM ('PAID', 'UNPAID');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "kickstarter_hosts" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "kickstarter_hosts_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "kickstarters" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"link" TEXT,
|
||||||
|
"filesUrl" TEXT,
|
||||||
|
"deliveryStatus" "DeliveryStatus" NOT NULL DEFAULT 'NOT_DELIVERED',
|
||||||
|
"paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'UNPAID',
|
||||||
|
"notes" TEXT,
|
||||||
|
"hostId" TEXT,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "kickstarters_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "kickstarter_packages" (
|
||||||
|
"kickstarterId" TEXT NOT NULL,
|
||||||
|
"packageId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "kickstarter_packages_pkey" PRIMARY KEY ("kickstarterId","packageId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "kickstarter_hosts_name_key" ON "kickstarter_hosts"("name");
|
||||||
|
CREATE INDEX "kickstarters_hostId_idx" ON "kickstarters"("hostId");
|
||||||
|
CREATE INDEX "kickstarters_userId_idx" ON "kickstarters"("userId");
|
||||||
|
CREATE INDEX "kickstarters_deliveryStatus_idx" ON "kickstarters"("deliveryStatus");
|
||||||
|
CREATE INDEX "kickstarters_paymentStatus_idx" ON "kickstarters"("paymentStatus");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "kickstarters" ADD CONSTRAINT "kickstarters_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "kickstarter_hosts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "kickstarters" ADD CONSTRAINT "kickstarters_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "kickstarter_packages" ADD CONSTRAINT "kickstarter_packages_kickstarterId_fkey" FOREIGN KEY ("kickstarterId") REFERENCES "kickstarters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "kickstarter_packages" ADD CONSTRAINT "kickstarter_packages_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "packages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -38,6 +38,7 @@ model User {
|
|||||||
tags Tag[]
|
tags Tag[]
|
||||||
settings UserSettings?
|
settings UserSettings?
|
||||||
telegramLink TelegramLink?
|
telegramLink TelegramLink?
|
||||||
|
kickstarters Kickstarter[]
|
||||||
inviteCodes InviteCode[] @relation("InviteCreator")
|
inviteCodes InviteCode[] @relation("InviteCreator")
|
||||||
usedInvite InviteCode? @relation("InviteUser", fields: [usedInviteId], references: [id], onDelete: SetNull)
|
usedInvite InviteCode? @relation("InviteUser", fields: [usedInviteId], references: [id], onDelete: SetNull)
|
||||||
usedInviteId String?
|
usedInviteId String?
|
||||||
@@ -468,6 +469,7 @@ model Package {
|
|||||||
isMultipart Boolean @default(false)
|
isMultipart Boolean @default(false)
|
||||||
partCount Int @default(1)
|
partCount Int @default(1)
|
||||||
fileCount Int @default(0)
|
fileCount Int @default(0)
|
||||||
|
tags String[] @default([])
|
||||||
previewData Bytes? // JPEG thumbnail from nearby Telegram photo (stored as raw bytes)
|
previewData Bytes? // JPEG thumbnail from nearby Telegram photo (stored as raw bytes)
|
||||||
previewMsgId BigInt? // Telegram message ID of the matched photo
|
previewMsgId BigInt? // Telegram message ID of the matched photo
|
||||||
indexedAt DateTime @default(now())
|
indexedAt DateTime @default(now())
|
||||||
@@ -479,6 +481,7 @@ model Package {
|
|||||||
ingestionRunId String?
|
ingestionRunId String?
|
||||||
sendRequests BotSendRequest[]
|
sendRequests BotSendRequest[]
|
||||||
extractRequests ArchiveExtractRequest[]
|
extractRequests ArchiveExtractRequest[]
|
||||||
|
kickstarterLinks KickstarterPackage[]
|
||||||
|
|
||||||
@@index([sourceChannelId])
|
@@index([sourceChannelId])
|
||||||
@@index([destChannelId])
|
@@index([destChannelId])
|
||||||
@@ -682,3 +685,63 @@ model ArchiveExtractRequest {
|
|||||||
@@index([status])
|
@@index([status])
|
||||||
@@map("archive_extract_requests")
|
@@map("archive_extract_requests")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────
|
||||||
|
// Purchased Kickstarters
|
||||||
|
// ───────────────────────────────────────
|
||||||
|
|
||||||
|
enum DeliveryStatus {
|
||||||
|
NOT_DELIVERED
|
||||||
|
PARTIAL
|
||||||
|
DELIVERED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PaymentStatus {
|
||||||
|
PAID
|
||||||
|
UNPAID
|
||||||
|
}
|
||||||
|
|
||||||
|
model KickstarterHost {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
kickstarters Kickstarter[]
|
||||||
|
|
||||||
|
@@map("kickstarter_hosts")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Kickstarter {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
link String?
|
||||||
|
filesUrl String?
|
||||||
|
deliveryStatus DeliveryStatus @default(NOT_DELIVERED)
|
||||||
|
paymentStatus PaymentStatus @default(UNPAID)
|
||||||
|
notes String?
|
||||||
|
hostId String?
|
||||||
|
userId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
host KickstarterHost? @relation(fields: [hostId], references: [id], onDelete: SetNull)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
packages KickstarterPackage[]
|
||||||
|
|
||||||
|
@@index([hostId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([deliveryStatus])
|
||||||
|
@@index([paymentStatus])
|
||||||
|
@@map("kickstarters")
|
||||||
|
}
|
||||||
|
|
||||||
|
model KickstarterPackage {
|
||||||
|
kickstarterId String
|
||||||
|
packageId String
|
||||||
|
|
||||||
|
kickstarter Kickstarter @relation(fields: [kickstarterId], references: [id], onDelete: Cascade)
|
||||||
|
package Package @relation(fields: [packageId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([kickstarterId, packageId])
|
||||||
|
@@map("kickstarter_packages")
|
||||||
|
}
|
||||||
|
|||||||
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";
|
"use client";
|
||||||
|
|
||||||
import { type ColumnDef } from "@tanstack/react-table";
|
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 { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -17,6 +17,7 @@ export interface PackageRow {
|
|||||||
isMultipart: boolean;
|
isMultipart: boolean;
|
||||||
hasPreview: boolean;
|
hasPreview: boolean;
|
||||||
creator: string | null;
|
creator: string | null;
|
||||||
|
tags: string[];
|
||||||
indexedAt: string;
|
indexedAt: string;
|
||||||
sourceChannel: {
|
sourceChannel: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,6 +28,7 @@ export interface PackageRow {
|
|||||||
interface PackageColumnsProps {
|
interface PackageColumnsProps {
|
||||||
onViewFiles: (pkg: PackageRow) => void;
|
onViewFiles: (pkg: PackageRow) => void;
|
||||||
onSetCreator: (pkg: PackageRow) => void;
|
onSetCreator: (pkg: PackageRow) => void;
|
||||||
|
onSetTags: (pkg: PackageRow) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytesStr: string): string {
|
function formatBytes(bytesStr: string): string {
|
||||||
@@ -59,6 +61,7 @@ function PreviewCell({ pkg }: { pkg: PackageRow }) {
|
|||||||
export function getPackageColumns({
|
export function getPackageColumns({
|
||||||
onViewFiles,
|
onViewFiles,
|
||||||
onSetCreator,
|
onSetCreator,
|
||||||
|
onSetTags,
|
||||||
}: PackageColumnsProps): ColumnDef<PackageRow, unknown>[] {
|
}: PackageColumnsProps): ColumnDef<PackageRow, unknown>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -124,6 +127,42 @@ export function getPackageColumns({
|
|||||||
</button>
|
</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",
|
id: "channel",
|
||||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Source" />,
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Source" />,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useCallback, useTransition } from "react";
|
import { useState, useCallback, useTransition } from "react";
|
||||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Search, FileBox } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import { useDataTable } from "@/hooks/use-data-table";
|
import { useDataTable } from "@/hooks/use-data-table";
|
||||||
import { getPackageColumns, type PackageRow } from "./package-columns";
|
import { getPackageColumns, type PackageRow } from "./package-columns";
|
||||||
import { PackageFilesDrawer } from "./package-files-drawer";
|
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 { DataTableViewOptions } from "@/components/shared/data-table-view-options";
|
||||||
import { PageHeader } from "@/components/shared/page-header";
|
import { PageHeader } from "@/components/shared/page-header";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import type { IngestionAccountStatus } from "@/lib/telegram/types";
|
import type { IngestionAccountStatus } from "@/lib/telegram/types";
|
||||||
import { updatePackageCreator } from "../actions";
|
import { updatePackageCreator, updatePackageTags } from "../actions";
|
||||||
|
|
||||||
interface StlTableProps {
|
interface StlTableProps {
|
||||||
data: PackageRow[];
|
data: PackageRow[];
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
ingestionStatus: IngestionAccountStatus[];
|
ingestionStatus: IngestionAccountStatus[];
|
||||||
|
availableTags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StlTable({
|
export function StlTable({
|
||||||
@@ -28,6 +36,7 @@ export function StlTable({
|
|||||||
pageCount,
|
pageCount,
|
||||||
totalCount,
|
totalCount,
|
||||||
ingestionStatus,
|
ingestionStatus,
|
||||||
|
availableTags,
|
||||||
}: StlTableProps) {
|
}: StlTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -52,6 +61,20 @@ export function StlTable({
|
|||||||
[router, pathname, searchParams]
|
[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({
|
const columns = getPackageColumns({
|
||||||
onViewFiles: (pkg) => setViewPkg(pkg),
|
onViewFiles: (pkg) => setViewPkg(pkg),
|
||||||
onSetCreator: (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 { table } = useDataTable({ data, columns, pageCount });
|
||||||
|
|
||||||
|
const activeTag = searchParams.get("tag") ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -90,6 +132,21 @@ export function StlTable({
|
|||||||
className="pl-9 h-9"
|
className="pl-9 h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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} />
|
<DataTableViewOptions table={table} />
|
||||||
</div>
|
</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(
|
export async function bulkSetCreator(
|
||||||
packageIds: string[],
|
packageIds: string[],
|
||||||
creator: string
|
creator: string
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { redirect } from "next/navigation";
|
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";
|
import { StlTable } from "./_components/stl-table";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,9 +19,10 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
|||||||
const order = (params.order as "asc" | "desc") ?? "desc";
|
const order = (params.order as "asc" | "desc") ?? "desc";
|
||||||
const search = (params.search as string) ?? "";
|
const search = (params.search as string) ?? "";
|
||||||
const creator = (params.creator as string) || undefined;
|
const creator = (params.creator as string) || undefined;
|
||||||
|
const tag = (params.tag as string) || undefined;
|
||||||
|
|
||||||
// Fetch packages and ingestion status in parallel
|
// Fetch packages, ingestion status, and available tags in parallel
|
||||||
const [result, ingestionStatus] = await Promise.all([
|
const [result, ingestionStatus, availableTags] = await Promise.all([
|
||||||
search
|
search
|
||||||
? searchPackages({
|
? searchPackages({
|
||||||
query: search,
|
query: search,
|
||||||
@@ -33,10 +34,12 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
|||||||
page,
|
page,
|
||||||
limit: perPage,
|
limit: perPage,
|
||||||
creator,
|
creator,
|
||||||
|
tag,
|
||||||
sortBy: sort as "indexedAt" | "fileName" | "fileSize",
|
sortBy: sort as "indexedAt" | "fileName" | "fileSize",
|
||||||
order,
|
order,
|
||||||
}),
|
}),
|
||||||
getIngestionStatus(),
|
getIngestionStatus(),
|
||||||
|
getAllPackageTags(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -45,6 +48,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
|||||||
pageCount={result.pagination.totalPages}
|
pageCount={result.pagination.totalPages}
|
||||||
totalCount={result.pagination.total}
|
totalCount={result.pagination.total}
|
||||||
ingestionStatus={ingestionStatus}
|
ingestionStatus={ingestionStatus}
|
||||||
|
availableTags={availableTags}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||||||
interface FetchedChannel {
|
interface FetchedChannel {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
title: string;
|
title: string;
|
||||||
type: "channel" | "supergroup";
|
type: string;
|
||||||
isForum: boolean;
|
isForum: boolean;
|
||||||
memberCount: number | null;
|
memberCount: number | null;
|
||||||
alreadyLinked: boolean;
|
alreadyLinked: boolean;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Paintbrush,
|
Paintbrush,
|
||||||
Gem,
|
Gem,
|
||||||
FileBox,
|
FileBox,
|
||||||
|
Gift,
|
||||||
Send,
|
Send,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Building2,
|
Building2,
|
||||||
@@ -22,7 +23,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { APP_NAME, NAV_ITEMS } from "@/lib/constants";
|
import { APP_NAME, NAV_ITEMS } from "@/lib/constants";
|
||||||
import { SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
import { SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
|
||||||
const icons = { LayoutDashboard, Cylinder, Droplets, Paintbrush, Gem, FileBox, Send, ClipboardList, Building2, MapPin, Settings, UserPlus };
|
const icons = { LayoutDashboard, Cylinder, Droplets, Paintbrush, Gem, FileBox, Gift, Send, ClipboardList, Building2, MapPin, Settings, UserPlus };
|
||||||
|
|
||||||
export function MobileSidebar() {
|
export function MobileSidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Paintbrush,
|
Paintbrush,
|
||||||
Gem,
|
Gem,
|
||||||
FileBox,
|
FileBox,
|
||||||
|
Gift,
|
||||||
Send,
|
Send,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Building2,
|
Building2,
|
||||||
@@ -33,6 +34,7 @@ const icons = {
|
|||||||
Paintbrush,
|
Paintbrush,
|
||||||
Gem,
|
Gem,
|
||||||
FileBox,
|
FileBox,
|
||||||
|
Gift,
|
||||||
Send,
|
Send,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Building2,
|
Building2,
|
||||||
|
|||||||
97
src/data/kickstarter.queries.ts
Normal file
97
src/data/kickstarter.queries.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import type { DataTableSearchParams } from "@/types/table.types";
|
||||||
|
|
||||||
|
interface KickstarterSearchParams extends DataTableSearchParams {
|
||||||
|
delivery?: string;
|
||||||
|
payment?: string;
|
||||||
|
host?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getKickstarters(
|
||||||
|
userId: string,
|
||||||
|
params: KickstarterSearchParams
|
||||||
|
) {
|
||||||
|
const page = Number(params.page) || 1;
|
||||||
|
const perPage = Number(params.perPage) || 20;
|
||||||
|
const skip = (page - 1) * perPage;
|
||||||
|
|
||||||
|
const where: Prisma.KickstarterWhereInput = {
|
||||||
|
userId,
|
||||||
|
...(params.search && {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
contains: params.search,
|
||||||
|
mode: "insensitive" as Prisma.QueryMode,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
notes: {
|
||||||
|
contains: params.search,
|
||||||
|
mode: "insensitive" as Prisma.QueryMode,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
...(params.delivery && {
|
||||||
|
deliveryStatus: params.delivery as Prisma.EnumDeliveryStatusFilter,
|
||||||
|
}),
|
||||||
|
...(params.payment && {
|
||||||
|
paymentStatus: params.payment as Prisma.EnumPaymentStatusFilter,
|
||||||
|
}),
|
||||||
|
...(params.host && { hostId: params.host }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortField = params.sort || "createdAt";
|
||||||
|
const sortOrder = params.order || "desc";
|
||||||
|
|
||||||
|
const [data, totalCount] = await Promise.all([
|
||||||
|
prisma.kickstarter.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { [sortField]: sortOrder },
|
||||||
|
skip,
|
||||||
|
take: perPage,
|
||||||
|
include: {
|
||||||
|
host: { select: { id: true, name: true } },
|
||||||
|
_count: { select: { packages: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.kickstarter.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
pageCount: Math.ceil(totalCount / perPage),
|
||||||
|
totalCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getKickstarterById(id: string, userId: string) {
|
||||||
|
return prisma.kickstarter.findFirst({
|
||||||
|
where: { id, userId },
|
||||||
|
include: {
|
||||||
|
host: { select: { id: true, name: true } },
|
||||||
|
packages: {
|
||||||
|
include: {
|
||||||
|
package: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fileName: true,
|
||||||
|
fileSize: true,
|
||||||
|
archiveType: true,
|
||||||
|
creator: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getKickstarterHosts() {
|
||||||
|
return prisma.kickstarterHost.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
include: { _count: { select: { kickstarters: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export const NAV_ITEMS = [
|
|||||||
{ label: "Paints", href: "/paints", icon: "Paintbrush", adminOnly: false },
|
{ label: "Paints", href: "/paints", icon: "Paintbrush", adminOnly: false },
|
||||||
{ label: "Supplies", href: "/supplies", icon: "Gem", adminOnly: false },
|
{ label: "Supplies", href: "/supplies", icon: "Gem", adminOnly: false },
|
||||||
{ label: "STL Files", href: "/stls", icon: "FileBox", adminOnly: false },
|
{ label: "STL Files", href: "/stls", icon: "FileBox", adminOnly: false },
|
||||||
|
{ label: "Kickstarters", href: "/kickstarters", icon: "Gift", adminOnly: false },
|
||||||
{ label: "Telegram", href: "/telegram", icon: "Send", adminOnly: true },
|
{ label: "Telegram", href: "/telegram", icon: "Send", adminOnly: true },
|
||||||
{ label: "Invites", href: "/invites", icon: "UserPlus", adminOnly: true },
|
{ label: "Invites", href: "/invites", icon: "UserPlus", adminOnly: true },
|
||||||
{ label: "Usage", href: "/usage", icon: "ClipboardList", adminOnly: false },
|
{ label: "Usage", href: "/usage", icon: "ClipboardList", adminOnly: false },
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ export async function listPackages(options: {
|
|||||||
limit: number;
|
limit: number;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
creator?: string;
|
creator?: string;
|
||||||
|
tag?: string;
|
||||||
sortBy: "indexedAt" | "fileName" | "fileSize";
|
sortBy: "indexedAt" | "fileName" | "fileSize";
|
||||||
order: "asc" | "desc";
|
order: "asc" | "desc";
|
||||||
}) {
|
}) {
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (options.channelId) where.sourceChannelId = options.channelId;
|
if (options.channelId) where.sourceChannelId = options.channelId;
|
||||||
if (options.creator) where.creator = options.creator;
|
if (options.creator) where.creator = options.creator;
|
||||||
|
if (options.tag) where.tags = { has: options.tag };
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
prisma.package.findMany({
|
prisma.package.findMany({
|
||||||
@@ -34,7 +36,8 @@ export async function listPackages(options: {
|
|||||||
isMultipart: true,
|
isMultipart: true,
|
||||||
indexedAt: true,
|
indexedAt: true,
|
||||||
creator: true,
|
creator: true,
|
||||||
previewMsgId: true, // cheap null check — avoids loading blob
|
tags: true,
|
||||||
|
previewData: true, // check actual image data, not previewMsgId proxy
|
||||||
sourceChannel: { select: { id: true, title: true } },
|
sourceChannel: { select: { id: true, title: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -49,8 +52,9 @@ export async function listPackages(options: {
|
|||||||
archiveType: pkg.archiveType,
|
archiveType: pkg.archiveType,
|
||||||
fileCount: pkg.fileCount,
|
fileCount: pkg.fileCount,
|
||||||
isMultipart: pkg.isMultipart,
|
isMultipart: pkg.isMultipart,
|
||||||
hasPreview: pkg.previewMsgId !== null,
|
hasPreview: pkg.previewData !== null,
|
||||||
creator: pkg.creator,
|
creator: pkg.creator,
|
||||||
|
tags: pkg.tags,
|
||||||
indexedAt: pkg.indexedAt.toISOString(),
|
indexedAt: pkg.indexedAt.toISOString(),
|
||||||
sourceChannel: pkg.sourceChannel,
|
sourceChannel: pkg.sourceChannel,
|
||||||
}));
|
}));
|
||||||
@@ -96,8 +100,9 @@ export async function getPackageById(
|
|||||||
archiveType: pkg.archiveType,
|
archiveType: pkg.archiveType,
|
||||||
fileCount: pkg.fileCount,
|
fileCount: pkg.fileCount,
|
||||||
isMultipart: pkg.isMultipart,
|
isMultipart: pkg.isMultipart,
|
||||||
hasPreview: pkg.previewMsgId !== null,
|
hasPreview: pkg.previewData !== null,
|
||||||
creator: pkg.creator,
|
creator: pkg.creator,
|
||||||
|
tags: pkg.tags,
|
||||||
partCount: pkg.partCount,
|
partCount: pkg.partCount,
|
||||||
indexedAt: pkg.indexedAt.toISOString(),
|
indexedAt: pkg.indexedAt.toISOString(),
|
||||||
sourceChannel: pkg.sourceChannel,
|
sourceChannel: pkg.sourceChannel,
|
||||||
@@ -208,7 +213,8 @@ export async function searchPackages(options: {
|
|||||||
isMultipart: true,
|
isMultipart: true,
|
||||||
indexedAt: true,
|
indexedAt: true,
|
||||||
creator: true,
|
creator: true,
|
||||||
previewMsgId: true,
|
tags: true,
|
||||||
|
previewData: true,
|
||||||
sourceChannel: { select: { id: true, title: true } },
|
sourceChannel: { select: { id: true, title: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -223,8 +229,9 @@ export async function searchPackages(options: {
|
|||||||
archiveType: pkg.archiveType,
|
archiveType: pkg.archiveType,
|
||||||
fileCount: pkg.fileCount,
|
fileCount: pkg.fileCount,
|
||||||
isMultipart: pkg.isMultipart,
|
isMultipart: pkg.isMultipart,
|
||||||
hasPreview: pkg.previewMsgId !== null,
|
hasPreview: pkg.previewData !== null,
|
||||||
creator: pkg.creator,
|
creator: pkg.creator,
|
||||||
|
tags: pkg.tags,
|
||||||
indexedAt: pkg.indexedAt.toISOString(),
|
indexedAt: pkg.indexedAt.toISOString(),
|
||||||
sourceChannel: pkg.sourceChannel,
|
sourceChannel: pkg.sourceChannel,
|
||||||
}));
|
}));
|
||||||
@@ -249,6 +256,16 @@ export async function searchPackages(options: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all distinct tags across all packages (for filter dropdowns).
|
||||||
|
*/
|
||||||
|
export async function getAllPackageTags(): Promise<string[]> {
|
||||||
|
const result = await prisma.$queryRaw<{ tag: string }[]>`
|
||||||
|
SELECT DISTINCT unnest(tags) AS tag FROM packages ORDER BY tag
|
||||||
|
`;
|
||||||
|
return result.map((r) => r.tag);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getIngestionStatus(): Promise<IngestionAccountStatus[]> {
|
export async function getIngestionStatus(): Promise<IngestionAccountStatus[]> {
|
||||||
const accounts = await prisma.telegramAccount.findMany({
|
const accounts = await prisma.telegramAccount.findMany({
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface PackageListItem {
|
|||||||
isMultipart: boolean;
|
isMultipart: boolean;
|
||||||
hasPreview: boolean;
|
hasPreview: boolean;
|
||||||
creator: string | null;
|
creator: string | null;
|
||||||
|
tags: string[];
|
||||||
indexedAt: string;
|
indexedAt: string;
|
||||||
sourceChannel: {
|
sourceChannel: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
19
src/schemas/kickstarter.schema.ts
Normal file
19
src/schemas/kickstarter.schema.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export const kickstarterSchema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required").max(200),
|
||||||
|
link: z.string().url().optional().or(z.literal("")),
|
||||||
|
filesUrl: z.string().url().optional().or(z.literal("")),
|
||||||
|
deliveryStatus: z.enum(["NOT_DELIVERED", "PARTIAL", "DELIVERED"]),
|
||||||
|
paymentStatus: z.enum(["PAID", "UNPAID"]),
|
||||||
|
hostId: z.string().optional().or(z.literal("")),
|
||||||
|
notes: z.string().max(2000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type KickstarterInput = z.infer<typeof kickstarterSchema>;
|
||||||
|
|
||||||
|
export const kickstarterHostSchema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required").max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type KickstarterHostInput = z.infer<typeof kickstarterHostSchema>;
|
||||||
@@ -103,6 +103,7 @@ export interface CreatePackageInput {
|
|||||||
partCount: number;
|
partCount: number;
|
||||||
ingestionRunId: string;
|
ingestionRunId: string;
|
||||||
creator?: string | null;
|
creator?: string | null;
|
||||||
|
tags?: string[];
|
||||||
previewData?: Buffer | null;
|
previewData?: Buffer | null;
|
||||||
previewMsgId?: bigint | null;
|
previewMsgId?: bigint | null;
|
||||||
files: {
|
files: {
|
||||||
@@ -132,6 +133,7 @@ export async function createPackageWithFiles(input: CreatePackageInput) {
|
|||||||
fileCount: input.files.length,
|
fileCount: input.files.length,
|
||||||
ingestionRunId: input.ingestionRunId,
|
ingestionRunId: input.ingestionRunId,
|
||||||
creator: input.creator ?? undefined,
|
creator: input.creator ?? undefined,
|
||||||
|
tags: input.tags && input.tags.length > 0 ? input.tags : undefined,
|
||||||
previewData: input.previewData ? new Uint8Array(input.previewData) : undefined,
|
previewData: input.previewData ? new Uint8Array(input.previewData) : undefined,
|
||||||
previewMsgId: input.previewMsgId ?? undefined,
|
previewMsgId: input.previewMsgId ?? undefined,
|
||||||
files: {
|
files: {
|
||||||
@@ -148,6 +150,7 @@ export async function createPackageWithFiles(input: CreatePackageInput) {
|
|||||||
packageId: pkg.id,
|
packageId: pkg.id,
|
||||||
fileName: input.fileName,
|
fileName: input.fileName,
|
||||||
creator: input.creator ?? null,
|
creator: input.creator ?? null,
|
||||||
|
tags: input.tags ?? [],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -16,24 +16,40 @@ export interface TelegramChatInfo {
|
|||||||
/**
|
/**
|
||||||
* Fetch all chats the account is a member of.
|
* Fetch all chats the account is a member of.
|
||||||
* Uses TDLib's getChats to load the chat list, then getChat for details.
|
* Uses TDLib's getChats to load the chat list, then getChat for details.
|
||||||
* Filters to channels and supergroups only (groups/privates are not useful for ingestion).
|
* Returns ALL chat types: channels, supergroups, groups, private chats,
|
||||||
|
* and the special "Saved Messages" (self) chat.
|
||||||
*/
|
*/
|
||||||
export async function getAccountChats(
|
export async function getAccountChats(
|
||||||
client: Client
|
client: Client
|
||||||
): Promise<TelegramChatInfo[]> {
|
): Promise<TelegramChatInfo[]> {
|
||||||
const chats: TelegramChatInfo[] = [];
|
const chats: TelegramChatInfo[] = [];
|
||||||
|
|
||||||
// Load main chat list — TDLib loads in batches
|
// Get the current user's ID so we can label Saved Messages
|
||||||
let offsetOrder = "9223372036854775807"; // max int64 as string
|
let selfUserId: number | null = null;
|
||||||
let offsetChatId = 0;
|
try {
|
||||||
let hasMore = true;
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const me = (await client.invoke({ _: "getMe" })) as any;
|
||||||
|
selfUserId = me.id;
|
||||||
|
} catch {
|
||||||
|
log.warn("Failed to get current user via getMe");
|
||||||
|
}
|
||||||
|
|
||||||
while (hasMore) {
|
// Load ALL chats from both main and archive lists by paginating getChats.
|
||||||
|
// TDLib's getChats returns batches — keep calling until it returns
|
||||||
|
// an empty list, which signals all chats have been loaded.
|
||||||
|
const seenChatIds = new Set<number>();
|
||||||
|
|
||||||
|
for (const chatList of [
|
||||||
|
{ _: "chatListMain" as const },
|
||||||
|
{ _: "chatListArchive" as const },
|
||||||
|
]) {
|
||||||
|
const MAX_PAGES = 500; // support up to 50,000 chats per list
|
||||||
|
for (let page = 0; page < MAX_PAGES; page++) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const result = (await withFloodWait(
|
const result = (await withFloodWait(
|
||||||
() => client.invoke({
|
() => client.invoke({
|
||||||
_: "getChats",
|
_: "getChats",
|
||||||
chat_list: { _: "chatListMain" },
|
chat_list: chatList,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
}),
|
}),
|
||||||
"getChats"
|
"getChats"
|
||||||
@@ -44,6 +60,9 @@ export async function getAccountChats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const chatId of result.chat_ids) {
|
for (const chatId of result.chat_ids) {
|
||||||
|
if (seenChatIds.has(chatId)) continue;
|
||||||
|
seenChatIds.add(chatId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const chat = (await withFloodWait(
|
const chat = (await withFloodWait(
|
||||||
@@ -57,6 +76,7 @@ export async function getAccountChats(
|
|||||||
const chatType = chat.type?._;
|
const chatType = chat.type?._;
|
||||||
let type: TelegramChatInfo["type"] = "other";
|
let type: TelegramChatInfo["type"] = "other";
|
||||||
let isForum = false;
|
let isForum = false;
|
||||||
|
let title = chat.title ?? `Chat ${chatId}`;
|
||||||
|
|
||||||
if (chatType === "chatTypeSupergroup") {
|
if (chatType === "chatTypeSupergroup") {
|
||||||
// Get supergroup details to check if it's a channel or group
|
// Get supergroup details to check if it's a channel or group
|
||||||
@@ -79,32 +99,30 @@ export async function getAccountChats(
|
|||||||
type = "group";
|
type = "group";
|
||||||
} else if (chatType === "chatTypePrivate" || chatType === "chatTypeSecret") {
|
} else if (chatType === "chatTypePrivate" || chatType === "chatTypeSecret") {
|
||||||
type = "private";
|
type = "private";
|
||||||
|
// Label the self-chat as "Saved Messages"
|
||||||
|
if (selfUserId !== null && chat.type?.user_id === selfUserId) {
|
||||||
|
title = "Saved Messages";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only include channels and supergroups
|
|
||||||
if (type === "channel" || type === "supergroup") {
|
|
||||||
chats.push({
|
chats.push({
|
||||||
chatId: BigInt(chatId),
|
chatId: BigInt(chatId),
|
||||||
title: chat.title ?? `Chat ${chatId}`,
|
title,
|
||||||
type,
|
type,
|
||||||
isForum,
|
isForum,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn({ chatId, err }, "Failed to get chat details, skipping");
|
log.warn({ chatId, err }, "Failed to get chat details, skipping");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getChats with chatListMain returns all chats at once in newer TDLib versions
|
|
||||||
// So we break after the first batch
|
|
||||||
hasMore = false;
|
|
||||||
|
|
||||||
await sleep(config.apiDelayMs);
|
await sleep(config.apiDelayMs);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
{ total: chats.length },
|
{ total: chats.length },
|
||||||
"Fetched channels/supergroups from Telegram"
|
"Fetched all chats from Telegram (main + archive)"
|
||||||
);
|
);
|
||||||
|
|
||||||
return chats;
|
return chats;
|
||||||
|
|||||||
@@ -335,17 +335,27 @@ export async function runWorkerForAccount(
|
|||||||
phone: account.phone,
|
phone: account.phone,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load the chat list so TDLib knows about all chats
|
// Load the full chat list so TDLib knows about all chats.
|
||||||
// Without this, getChat/getChatHistory fail with "Chat not found"
|
// Without this, getChat/searchChatMessages fail with "Chat not found".
|
||||||
|
// TDLib returns chats in batches — keep calling until empty.
|
||||||
|
// Load from both main and archive lists to cover older/archived chats.
|
||||||
|
for (const chatList of [
|
||||||
|
{ _: "chatListMain" as const },
|
||||||
|
{ _: "chatListArchive" as const },
|
||||||
|
]) {
|
||||||
try {
|
try {
|
||||||
await client.invoke({
|
for (let page = 0; page < 500; page++) {
|
||||||
|
const chatResult = await client.invoke({
|
||||||
_: "getChats",
|
_: "getChats",
|
||||||
chat_list: { _: "chatListMain" },
|
chat_list: chatList,
|
||||||
limit: 1000,
|
limit: 100,
|
||||||
});
|
}) as { chat_ids?: number[] };
|
||||||
|
if (!chatResult.chat_ids || chatResult.chat_ids.length === 0) break;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore — chat list may already be loaded
|
// Ignore — chat list may already be loaded
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const counters = {
|
const counters = {
|
||||||
messagesScanned: 0,
|
messagesScanned: 0,
|
||||||
@@ -377,6 +387,22 @@ export async function runWorkerForAccount(
|
|||||||
: channel.title;
|
: channel.title;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// ── Ensure TDLib knows about this chat ──
|
||||||
|
// getChats may not have loaded all channels (pagination, archive folder, etc.)
|
||||||
|
// so we explicitly load each channel before scanning.
|
||||||
|
try {
|
||||||
|
await client.invoke({
|
||||||
|
_: "getChat",
|
||||||
|
chat_id: Number(channel.telegramId),
|
||||||
|
});
|
||||||
|
} catch (chatErr) {
|
||||||
|
accountLog.warn(
|
||||||
|
{ err: chatErr, channelId: channel.id, title: channel.title, telegramId: channel.telegramId.toString() },
|
||||||
|
"TDLib does not know about this chat — it may not be accessible to this account. Skipping."
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Check if channel is a forum ──
|
// ── Check if channel is a forum ──
|
||||||
const forum = await isChatForum(client, channel.telegramId);
|
const forum = await isChatForum(client, channel.telegramId);
|
||||||
if (forum !== channel.isForum) {
|
if (forum !== channel.isForum) {
|
||||||
@@ -969,8 +995,12 @@ async function processOneArchiveSet(
|
|||||||
totalFiles: totalSets,
|
totalFiles: totalSets,
|
||||||
});
|
});
|
||||||
previewData = await downloadPhotoThumbnail(client, matchedPhoto.fileId);
|
previewData = await downloadPhotoThumbnail(client, matchedPhoto.fileId);
|
||||||
|
// Only set previewMsgId if we actually got the image data —
|
||||||
|
// otherwise the UI thinks there's a preview but the API returns 404
|
||||||
|
if (previewData) {
|
||||||
previewMsgId = matchedPhoto.id;
|
previewMsgId = matchedPhoto.id;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Fallback: extract preview image from inside the archive ──
|
// ── Fallback: extract preview image from inside the archive ──
|
||||||
if (!previewData && entries.length > 0 && archiveSet.type !== "DOCUMENT") {
|
if (!previewData && entries.length > 0 && archiveSet.type !== "DOCUMENT") {
|
||||||
@@ -1008,6 +1038,12 @@ async function processOneArchiveSet(
|
|||||||
// Clean up any orphaned record (same hash but no dest upload) before creating
|
// Clean up any orphaned record (same hash but no dest upload) before creating
|
||||||
await deleteOrphanedPackageByHash(contentHash);
|
await deleteOrphanedPackageByHash(contentHash);
|
||||||
|
|
||||||
|
// Auto-inherit source channel category as initial tag
|
||||||
|
const tags: string[] = [];
|
||||||
|
if (channel.category) {
|
||||||
|
tags.push(channel.category);
|
||||||
|
}
|
||||||
|
|
||||||
await createPackageWithFiles({
|
await createPackageWithFiles({
|
||||||
contentHash,
|
contentHash,
|
||||||
fileName: archiveName,
|
fileName: archiveName,
|
||||||
@@ -1023,6 +1059,7 @@ async function processOneArchiveSet(
|
|||||||
partCount: uploadPaths.length,
|
partCount: uploadPaths.length,
|
||||||
ingestionRunId,
|
ingestionRunId,
|
||||||
creator,
|
creator,
|
||||||
|
tags,
|
||||||
previewData,
|
previewData,
|
||||||
previewMsgId,
|
previewMsgId,
|
||||||
files: entries,
|
files: entries,
|
||||||
|
|||||||
Reference in New Issue
Block a user