mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 06:11:15 +00:00
addd TG integration
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
Link2,
|
||||
Play,
|
||||
KeyRound,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -36,6 +37,7 @@ interface AccountColumnsProps {
|
||||
onViewLinks: (id: string) => void;
|
||||
onTriggerSync: (id: string) => void;
|
||||
onEnterCode: (account: AccountRow) => void;
|
||||
onFetchChannels: (id: string) => void;
|
||||
}
|
||||
|
||||
export function getAccountColumns({
|
||||
@@ -45,6 +47,7 @@ export function getAccountColumns({
|
||||
onViewLinks,
|
||||
onTriggerSync,
|
||||
onEnterCode,
|
||||
onFetchChannels,
|
||||
}: AccountColumnsProps): ColumnDef<AccountRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
@@ -157,6 +160,13 @@ export function getAccountColumns({
|
||||
<Link2 className="mr-2 h-3.5 w-3.5" />
|
||||
Manage Channels
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onFetchChannels(row.original.id)}
|
||||
disabled={row.original.authState !== "AUTHENTICATED"}
|
||||
>
|
||||
<Download className="mr-2 h-3.5 w-3.5" />
|
||||
Fetch Channels
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onTriggerSync(row.original.id)}>
|
||||
<Play className="mr-2 h-3.5 w-3.5" />
|
||||
Sync Now
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useState, useEffect, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, Play } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getAccountColumns } from "./account-columns";
|
||||
import { AccountModal } from "./account-modal";
|
||||
import { AccountLinksDrawer } from "./account-links-drawer";
|
||||
import { AuthCodeDialog } from "./auth-code-dialog";
|
||||
import { ChannelPickerDialog } from "./channel-picker-dialog";
|
||||
import { deleteAccount, toggleAccountActive, triggerIngestion } from "../actions";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
import { DeleteDialog } from "@/components/shared/delete-dialog";
|
||||
@@ -19,12 +21,27 @@ interface AccountsTabProps {
|
||||
}
|
||||
|
||||
export function AccountsTab({ accounts }: AccountsTabProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editAccount, setEditAccount] = useState<AccountRow | undefined>();
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [linksAccountId, setLinksAccountId] = useState<string | null>(null);
|
||||
const [authCodeAccount, setAuthCodeAccount] = useState<AccountRow | null>(null);
|
||||
const [fetchChannelsAccountId, setFetchChannelsAccountId] = useState<string | null>(null);
|
||||
|
||||
// Auto-refresh when accounts are in transitional states (PENDING, AWAITING_CODE, AWAITING_PASSWORD)
|
||||
const hasTransitional = accounts.some(
|
||||
(a) => a.authState === "PENDING" || a.authState === "AWAITING_CODE" || a.authState === "AWAITING_PASSWORD"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasTransitional) return;
|
||||
const interval = setInterval(() => {
|
||||
router.refresh();
|
||||
}, 3_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [hasTransitional, router]);
|
||||
|
||||
const columns = getAccountColumns({
|
||||
onEdit: (account) => {
|
||||
@@ -48,6 +65,7 @@ export function AccountsTab({ accounts }: AccountsTabProps) {
|
||||
else toast.error(result.error);
|
||||
});
|
||||
},
|
||||
onFetchChannels: (id) => setFetchChannelsAccountId(id),
|
||||
});
|
||||
|
||||
const { table } = useDataTable({
|
||||
@@ -135,6 +153,14 @@ export function AccountsTab({ accounts }: AccountsTabProps) {
|
||||
if (!open) setAuthCodeAccount(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChannelPickerDialog
|
||||
accountId={fetchChannelsAccountId}
|
||||
open={!!fetchChannelsAccountId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setFetchChannelsAccountId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Power,
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -19,15 +20,15 @@ import {
|
||||
import type { ChannelRow } from "@/lib/telegram/admin-queries";
|
||||
|
||||
interface ChannelColumnsProps {
|
||||
onEdit: (channel: ChannelRow) => void;
|
||||
onToggleActive: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void;
|
||||
}
|
||||
|
||||
export function getChannelColumns({
|
||||
onEdit,
|
||||
onToggleActive,
|
||||
onDelete,
|
||||
onSetType,
|
||||
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
@@ -105,10 +106,21 @@ export function getChannelColumns({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{row.original.type === "SOURCE" ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSetType(row.original.id, "DESTINATION")}
|
||||
>
|
||||
<ArrowDownToLine className="mr-2 h-3.5 w-3.5" />
|
||||
Set as Destination
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSetType(row.original.id, "SOURCE")}
|
||||
>
|
||||
<ArrowUpFromLine className="mr-2 h-3.5 w-3.5" />
|
||||
Set as Source
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onToggleActive(row.original.id)}
|
||||
>
|
||||
|
||||
337
src/app/(app)/telegram/_components/channel-picker-dialog.tsx
Normal file
337
src/app/(app)/telegram/_components/channel-picker-dialog.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useTransition } from "react";
|
||||
import { Loader2, Search, CheckSquare, Square, Radio } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { saveChannelSelections } from "../actions";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface FetchedChannel {
|
||||
chatId: string;
|
||||
title: string;
|
||||
type: "channel" | "supergroup";
|
||||
isForum: boolean;
|
||||
memberCount: number | null;
|
||||
alreadyLinked: boolean;
|
||||
existingChannelId: string | null;
|
||||
}
|
||||
|
||||
interface ChannelPickerDialogProps {
|
||||
accountId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type FetchState =
|
||||
| { phase: "idle" }
|
||||
| { phase: "fetching"; requestId?: string }
|
||||
| { phase: "loaded"; channels: FetchedChannel[] }
|
||||
| { phase: "error"; message: string };
|
||||
|
||||
export function ChannelPickerDialog({
|
||||
accountId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ChannelPickerDialogProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [fetchState, setFetchState] = useState<FetchState>({ phase: "idle" });
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Start fetching when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open || !accountId) {
|
||||
setFetchState({ phase: "idle" });
|
||||
setSelected(new Set());
|
||||
setSearch("");
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
|
||||
const startFetch = async () => {
|
||||
setFetchState({ phase: "fetching" });
|
||||
|
||||
try {
|
||||
// POST to create a fetch request
|
||||
const postRes = await fetch(
|
||||
`/api/telegram/accounts/${accountId}/fetch-channels`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
|
||||
if (!postRes.ok) {
|
||||
let message = `Server error (${postRes.status})`;
|
||||
try {
|
||||
const err = await postRes.json();
|
||||
message = err.error || message;
|
||||
} catch {
|
||||
// response wasn't JSON
|
||||
}
|
||||
if (mounted) setFetchState({ phase: "error", message });
|
||||
return;
|
||||
}
|
||||
|
||||
const { requestId } = await postRes.json();
|
||||
if (mounted) setFetchState({ phase: "fetching", requestId });
|
||||
|
||||
// Poll for result
|
||||
const poll = async () => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
if (!mounted) return;
|
||||
|
||||
const getRes = await fetch(
|
||||
`/api/telegram/accounts/${accountId}/fetch-channels?requestId=${requestId}`
|
||||
);
|
||||
if (!getRes.ok) continue;
|
||||
|
||||
const data = await getRes.json();
|
||||
if (data.status === "COMPLETED") {
|
||||
if (mounted) {
|
||||
// Filter out already-linked channels
|
||||
const available = (data.channels as FetchedChannel[]).filter(
|
||||
(ch) => !ch.alreadyLinked
|
||||
);
|
||||
setFetchState({ phase: "loaded", channels: available });
|
||||
}
|
||||
return;
|
||||
} else if (data.status === "FAILED") {
|
||||
if (mounted) {
|
||||
setFetchState({
|
||||
phase: "error",
|
||||
message: data.error || "Fetch failed",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setFetchState({ phase: "error", message: "Fetch timed out" });
|
||||
}
|
||||
};
|
||||
|
||||
await poll();
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
const message = err instanceof Error ? err.message : "Network error";
|
||||
setFetchState({ phase: "error", message: `Network error: ${message}` });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
startFetch();
|
||||
return () => { mounted = false; };
|
||||
}, [open, accountId]);
|
||||
|
||||
const channels =
|
||||
fetchState.phase === "loaded" ? fetchState.channels : [];
|
||||
|
||||
const filteredChannels = channels.filter((ch) =>
|
||||
ch.title.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const toggleChannel = (chatId: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(chatId)) {
|
||||
next.delete(chatId);
|
||||
} else {
|
||||
next.add(chatId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelected(new Set(filteredChannels.map((ch) => ch.chatId)));
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!accountId || selected.size === 0) return;
|
||||
|
||||
const selectedChannels = channels
|
||||
.filter((ch) => selected.has(ch.chatId))
|
||||
.map((ch) => ({
|
||||
telegramId: ch.chatId,
|
||||
title: ch.title,
|
||||
isForum: ch.isForum,
|
||||
}));
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await saveChannelSelections(accountId, selectedChannels);
|
||||
if (result.success) {
|
||||
toast.success(`${selectedChannels.length} channel(s) linked as source`);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select Source Channels</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose which channels to scan for archives. Already-linked channels
|
||||
are hidden.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{fetchState.phase === "fetching" && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fetching channels from Telegram...
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This may take a few seconds
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fetchState.phase === "error" && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12">
|
||||
<p className="text-sm text-destructive">{fetchState.message}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Reopen to re-trigger fetch
|
||||
onOpenChange(false);
|
||||
setTimeout(() => onOpenChange(true), 100);
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fetchState.phase === "loaded" && (
|
||||
<>
|
||||
{channels.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-12">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
All channels are already linked to this account.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Search + bulk actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter channels..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={selectAll}>
|
||||
All
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={deselectAll}>
|
||||
None
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{filteredChannels.length} channel(s) available
|
||||
{selected.size > 0 && ` \u2014 ${selected.size} selected`}
|
||||
</p>
|
||||
|
||||
{/* Channel list */}
|
||||
<ScrollArea className="flex-1 max-h-[400px] -mx-2 px-2">
|
||||
<div className="space-y-1">
|
||||
{filteredChannels.map((ch) => (
|
||||
<label
|
||||
key={ch.chatId}
|
||||
className="flex items-center gap-3 rounded-md border p-3 cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.has(ch.chatId)}
|
||||
onCheckedChange={() => toggleChannel(ch.chatId)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{ch.title}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] shrink-0"
|
||||
>
|
||||
{ch.type}
|
||||
</Badge>
|
||||
{ch.isForum && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] shrink-0"
|
||||
>
|
||||
forum
|
||||
</Badge>
|
||||
)}
|
||||
{!ch.existingChannelId && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] bg-emerald-500/10 text-emerald-600 border-emerald-500/20 shrink-0"
|
||||
>
|
||||
new
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
ID: {ch.chatId}
|
||||
{ch.memberCount ? ` \u2022 ${ch.memberCount} members` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
isPending ||
|
||||
selected.size === 0 ||
|
||||
fetchState.phase !== "loaded"
|
||||
}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Link {selected.size} Channel{selected.size !== 1 ? "s" : ""}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getChannelColumns } from "./channel-columns";
|
||||
import { ChannelModal } from "./channel-modal";
|
||||
import { deleteChannel, toggleChannelActive } from "../actions";
|
||||
import { DestinationCard } from "./destination-card";
|
||||
import {
|
||||
deleteChannel,
|
||||
toggleChannelActive,
|
||||
setChannelType,
|
||||
} from "../actions";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
import { DeleteDialog } from "@/components/shared/delete-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ChannelRow } from "@/lib/telegram/admin-queries";
|
||||
import type { ChannelRow, GlobalDestination } from "@/lib/telegram/admin-queries";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
|
||||
interface ChannelsTabProps {
|
||||
channels: ChannelRow[];
|
||||
globalDestination: GlobalDestination;
|
||||
}
|
||||
|
||||
export function ChannelsTab({ channels }: ChannelsTabProps) {
|
||||
export function ChannelsTab({ channels, globalDestination }: ChannelsTabProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editChannel, setEditChannel] = useState<ChannelRow | undefined>();
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
const columns = getChannelColumns({
|
||||
onEdit: (channel) => {
|
||||
setEditChannel(channel);
|
||||
setModalOpen(true);
|
||||
},
|
||||
onToggleActive: (id) => {
|
||||
startTransition(async () => {
|
||||
const result = await toggleChannelActive(id);
|
||||
@@ -35,6 +32,13 @@ export function ChannelsTab({ channels }: ChannelsTabProps) {
|
||||
});
|
||||
},
|
||||
onDelete: (id) => setDeleteId(id),
|
||||
onSetType: (id, type) => {
|
||||
startTransition(async () => {
|
||||
const result = await setChannelType(id, type);
|
||||
if (result.success) toast.success(`Channel set as ${type.toLowerCase()}`);
|
||||
else toast.error(result.error);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { table } = useDataTable({
|
||||
@@ -58,30 +62,17 @@ export function ChannelsTab({ channels }: ChannelsTabProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditChannel(undefined);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Channel
|
||||
</Button>
|
||||
</div>
|
||||
<DestinationCard destination={globalDestination} />
|
||||
|
||||
{channels.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Source channels are added per-account via the "Fetch Channels" button on the Accounts tab.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
emptyMessage="No channels configured. Add a Telegram channel to start ingesting."
|
||||
/>
|
||||
|
||||
<ChannelModal
|
||||
open={modalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setModalOpen(open);
|
||||
if (!open) setEditChannel(undefined);
|
||||
}}
|
||||
channel={editChannel}
|
||||
emptyMessage="No channels yet. Use "Fetch Channels" on an account to discover and add source channels."
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
|
||||
287
src/app/(app)/telegram/_components/destination-card.tsx
Normal file
287
src/app/(app)/telegram/_components/destination-card.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useTransition } from "react";
|
||||
import { Database, AlertTriangle, Link2, Plus, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { createDestinationViaWorker } from "../actions";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { GlobalDestination } from "@/lib/telegram/admin-queries";
|
||||
|
||||
interface DestinationCardProps {
|
||||
destination: GlobalDestination;
|
||||
}
|
||||
|
||||
type CreateState =
|
||||
| { phase: "idle" }
|
||||
| { phase: "creating"; requestId?: string }
|
||||
| { phase: "done"; title: string; telegramId: string }
|
||||
| { phase: "error"; message: string };
|
||||
|
||||
export function DestinationCard({ destination }: DestinationCardProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [title, setTitle] = useState("dragonsstash db");
|
||||
const [createState, setCreateState] = useState<CreateState>({ phase: "idle" });
|
||||
|
||||
// Poll for worker result when creating
|
||||
useEffect(() => {
|
||||
if (createState.phase !== "creating" || !createState.requestId) return;
|
||||
|
||||
let mounted = true;
|
||||
const requestId = createState.requestId;
|
||||
|
||||
const poll = async () => {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/telegram/worker-request?requestId=${requestId}`
|
||||
);
|
||||
if (!res.ok) continue;
|
||||
|
||||
const data = await res.json();
|
||||
if (data.status === "COMPLETED" && data.result) {
|
||||
if (mounted) {
|
||||
setCreateState({
|
||||
phase: "done",
|
||||
title: data.result.title,
|
||||
telegramId: data.result.telegramId,
|
||||
});
|
||||
toast.success(`Telegram group "${data.result.title}" created and set as destination!`);
|
||||
setCreateOpen(false);
|
||||
// Refresh the page to show the new destination
|
||||
window.location.reload();
|
||||
}
|
||||
return;
|
||||
} else if (data.status === "FAILED") {
|
||||
if (mounted) {
|
||||
setCreateState({
|
||||
phase: "error",
|
||||
message: data.error || "Worker failed to create the group",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Network blip — keep polling
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setCreateState({ phase: "error", message: "Timed out waiting for the worker" });
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
return () => { mounted = false; };
|
||||
}, [createState]);
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!title.trim()) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createDestinationViaWorker(title.trim());
|
||||
if (result.success) {
|
||||
setCreateState({ phase: "creating", requestId: result.data.requestId });
|
||||
} else {
|
||||
setCreateState({ phase: "error", message: result.error ?? "Unknown error" });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setCreateOpen(open);
|
||||
if (!open) {
|
||||
// Reset state when closing (unless actively creating)
|
||||
if (createState.phase !== "creating") {
|
||||
setCreateState({ phase: "idle" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!destination) {
|
||||
return (
|
||||
<>
|
||||
<Card className="border-dashed border-yellow-500/40">
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-500 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
No destination channel configured
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create a private Telegram group that all accounts will write
|
||||
archives to. Requires at least one authenticated account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
Create Destination
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreateDestinationDialog
|
||||
open={createOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
title={title}
|
||||
setTitle={setTitle}
|
||||
onSubmit={handleCreate}
|
||||
createState={createState}
|
||||
isPending={isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="h-5 w-5 text-purple-500 shrink-0" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{destination.title}</p>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-purple-500/10 text-purple-600 border-purple-500/20 text-[10px]"
|
||||
>
|
||||
DESTINATION
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>ID: {destination.telegramId}</span>
|
||||
{destination.inviteLink && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Link2 className="h-3 w-3" />
|
||||
Invite link active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreateDestinationDialog
|
||||
open={createOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
title={title}
|
||||
setTitle={setTitle}
|
||||
onSubmit={handleCreate}
|
||||
createState={createState}
|
||||
isPending={isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateDestinationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
setTitle,
|
||||
onSubmit,
|
||||
createState,
|
||||
isPending,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
setTitle: (v: string) => void;
|
||||
onSubmit: () => void;
|
||||
createState: CreateState;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const isCreating = createState.phase === "creating";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Destination Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
A private Telegram group will be created automatically using one of
|
||||
your authenticated accounts. All accounts will write archives here.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isCreating ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Creating Telegram group...
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This may take a few seconds
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{createState.phase === "error" && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||
<p className="text-sm text-destructive">{createState.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dest-title">Group Name</Label>
|
||||
<Input
|
||||
id="dest-title"
|
||||
placeholder="e.g. dragonsstash db"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This will be the name of the Telegram group. You can rename it later in Telegram.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
disabled={isPending || isCreating || !title.trim()}
|
||||
>
|
||||
{(isPending || isCreating) && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Create Group
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,14 +4,23 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { AccountsTab } from "./accounts-tab";
|
||||
import { ChannelsTab } from "./channels-tab";
|
||||
import type { AccountRow, ChannelRow } from "@/lib/telegram/admin-queries";
|
||||
import { WorkerStatusPanel } from "./worker-status-panel";
|
||||
import type { AccountRow, ChannelRow, GlobalDestination } from "@/lib/telegram/admin-queries";
|
||||
import type { IngestionAccountStatus } from "@/lib/telegram/types";
|
||||
|
||||
interface TelegramAdminProps {
|
||||
accounts: AccountRow[];
|
||||
channels: ChannelRow[];
|
||||
ingestionStatus: IngestionAccountStatus[];
|
||||
globalDestination: GlobalDestination;
|
||||
}
|
||||
|
||||
export function TelegramAdmin({ accounts, channels }: TelegramAdminProps) {
|
||||
export function TelegramAdmin({
|
||||
accounts,
|
||||
channels,
|
||||
ingestionStatus,
|
||||
globalDestination,
|
||||
}: TelegramAdminProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
@@ -19,6 +28,8 @@ export function TelegramAdmin({ accounts, channels }: TelegramAdminProps) {
|
||||
description="Manage Telegram accounts, channels, and ingestion"
|
||||
/>
|
||||
|
||||
<WorkerStatusPanel initialStatus={ingestionStatus} />
|
||||
|
||||
<Tabs defaultValue="accounts" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="accounts">
|
||||
@@ -33,7 +44,7 @@ export function TelegramAdmin({ accounts, channels }: TelegramAdminProps) {
|
||||
<AccountsTab accounts={accounts} />
|
||||
</TabsContent>
|
||||
<TabsContent value="channels">
|
||||
<ChannelsTab channels={channels} />
|
||||
<ChannelsTab channels={channels} globalDestination={globalDestination} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
340
src/app/(app)/telegram/_components/worker-status-panel.tsx
Normal file
340
src/app/(app)/telegram/_components/worker-status-panel.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Radio,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { IngestionAccountStatus } from "@/lib/telegram/types";
|
||||
|
||||
interface WorkerStatusPanelProps {
|
||||
initialStatus: IngestionAccountStatus[];
|
||||
}
|
||||
|
||||
const AUTH_STATE_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; color: string; icon: string }
|
||||
> = {
|
||||
PENDING: { label: "Pending", color: "text-yellow-500", icon: "clock" },
|
||||
AWAITING_CODE: {
|
||||
label: "Awaiting Code",
|
||||
color: "text-orange-500",
|
||||
icon: "alert",
|
||||
},
|
||||
AWAITING_PASSWORD: {
|
||||
label: "Awaiting Password",
|
||||
color: "text-orange-500",
|
||||
icon: "alert",
|
||||
},
|
||||
AUTHENTICATED: { label: "Connected", color: "text-emerald-500", icon: "check" },
|
||||
EXPIRED: { label: "Expired", color: "text-red-500", icon: "x" },
|
||||
};
|
||||
|
||||
export function WorkerStatusPanel({ initialStatus }: WorkerStatusPanelProps) {
|
||||
const [accounts, setAccounts] = useState(initialStatus);
|
||||
const [error, setError] = useState(false);
|
||||
const [nextRunCountdown, setNextRunCountdown] = useState<string | null>(null);
|
||||
|
||||
// Find active run
|
||||
const activeRun = accounts.find((a) => a.currentRun);
|
||||
const isRunning = !!activeRun;
|
||||
|
||||
// Poll for status
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let mounted = true;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/ingestion/status");
|
||||
if (!res.ok) throw new Error("fetch failed");
|
||||
const data = await res.json();
|
||||
if (mounted) {
|
||||
setAccounts(data.accounts ?? []);
|
||||
setError(false);
|
||||
}
|
||||
} catch {
|
||||
if (mounted) setError(true);
|
||||
}
|
||||
if (mounted) {
|
||||
const interval = accounts.some((a) => a.currentRun) ? 2_000 : 10_000;
|
||||
timer = setTimeout(poll, interval);
|
||||
}
|
||||
};
|
||||
|
||||
timer = setTimeout(poll, 2_000);
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isRunning]);
|
||||
|
||||
// Countdown timer to next run
|
||||
useEffect(() => {
|
||||
if (isRunning) {
|
||||
setNextRunCountdown(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Estimate next run based on last run finish time + interval (5 min + up to 5 min jitter)
|
||||
const lastFinished = accounts
|
||||
.filter((a) => a.lastRun?.finishedAt)
|
||||
.map((a) => new Date(a.lastRun!.finishedAt!).getTime())
|
||||
.sort((a, b) => b - a)[0];
|
||||
|
||||
if (!lastFinished) {
|
||||
setNextRunCountdown(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = 5 * 60 * 1000; // 5 min base
|
||||
const estimatedNext = lastFinished + intervalMs;
|
||||
|
||||
const tick = () => {
|
||||
const remaining = estimatedNext - Date.now();
|
||||
if (remaining <= 0) {
|
||||
setNextRunCountdown("any moment...");
|
||||
} else {
|
||||
const mins = Math.floor(remaining / 60_000);
|
||||
const secs = Math.floor((remaining % 60_000) / 1_000);
|
||||
setNextRunCountdown(
|
||||
mins > 0 ? `~${mins}m ${secs}s` : `~${secs}s`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
const interval = setInterval(tick, 1_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isRunning, accounts]);
|
||||
|
||||
if (accounts.length === 0 && !error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-500 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">No accounts configured</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add a Telegram account below to get started. You'll need your
|
||||
phone number and the API credentials in your .env.local file.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-4 space-y-3">
|
||||
{/* Account status row */}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
{accounts.map((account) => {
|
||||
const config = AUTH_STATE_CONFIG[account.authState] ?? AUTH_STATE_CONFIG.PENDING;
|
||||
return (
|
||||
<div key={account.id} className="flex items-center gap-2">
|
||||
{config.icon === "check" && (
|
||||
<CheckCircle2 className={cn("h-4 w-4", config.color)} />
|
||||
)}
|
||||
{config.icon === "clock" && (
|
||||
<Clock className={cn("h-4 w-4", config.color)} />
|
||||
)}
|
||||
{config.icon === "alert" && (
|
||||
<AlertTriangle className={cn("h-4 w-4", config.color)} />
|
||||
)}
|
||||
{config.icon === "x" && (
|
||||
<XCircle className={cn("h-4 w-4", config.color)} />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{account.displayName || account.phone}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-[10px]", config.color)}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t" />
|
||||
|
||||
{/* Worker activity */}
|
||||
{error ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
<span>Could not reach worker status</span>
|
||||
</div>
|
||||
) : isRunning && activeRun?.currentRun ? (
|
||||
<RunningStatus run={activeRun.currentRun} />
|
||||
) : (
|
||||
<IdleStatus accounts={accounts} nextRunCountdown={nextRunCountdown} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RunningStatus({
|
||||
run,
|
||||
}: {
|
||||
run: NonNullable<IngestionAccountStatus["currentRun"]>;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary shrink-0" />
|
||||
<span className="text-sm font-medium text-primary truncate">
|
||||
{run.currentActivity ?? "Working..."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar for downloads */}
|
||||
{run.downloadPercent != null && run.downloadPercent > 0 && (
|
||||
<div className="flex items-center gap-3 pl-6">
|
||||
<div className="h-1.5 flex-1 max-w-[200px] rounded-full bg-primary/20">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${Math.min(100, run.downloadPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-primary/70 tabular-nums">
|
||||
{run.downloadPercent}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats line */}
|
||||
<div className="flex items-center gap-4 pl-6 text-xs text-muted-foreground">
|
||||
{run.currentChannel && (
|
||||
<span>
|
||||
Channel: <span className="text-foreground">{run.currentChannel}</span>
|
||||
</span>
|
||||
)}
|
||||
{run.totalFiles != null && run.currentFileNum != null && (
|
||||
<span>
|
||||
Archive{" "}
|
||||
<span className="text-foreground tabular-nums">
|
||||
{run.currentFileNum}/{run.totalFiles}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{run.zipsIngested > 0 && (
|
||||
<span>
|
||||
<span className="text-foreground tabular-nums">{run.zipsIngested}</span> ingested
|
||||
</span>
|
||||
)}
|
||||
{run.zipsDuplicate > 0 && (
|
||||
<span>
|
||||
<span className="text-foreground tabular-nums">{run.zipsDuplicate}</span> skipped
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IdleStatus({
|
||||
accounts,
|
||||
nextRunCountdown,
|
||||
}: {
|
||||
accounts: IngestionAccountStatus[];
|
||||
nextRunCountdown: string | null;
|
||||
}) {
|
||||
const lastRun = accounts
|
||||
.filter((a) => a.lastRun)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.lastRun!.finishedAt ?? b.lastRun!.startedAt).getTime() -
|
||||
new Date(a.lastRun!.finishedAt ?? a.lastRun!.startedAt).getTime()
|
||||
)[0]?.lastRun;
|
||||
|
||||
const hasAuthenticated = accounts.some(
|
||||
(a) => a.authState === "AUTHENTICATED"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{lastRun ? (
|
||||
<>
|
||||
{lastRun.status === "FAILED" ? (
|
||||
<XCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{lastRun.status === "FAILED"
|
||||
? `Last sync failed ${getTimeAgo(lastRun.finishedAt ?? lastRun.startedAt)}`
|
||||
: `Last sync ${getTimeAgo(lastRun.finishedAt ?? lastRun.startedAt)} — ${lastRun.zipsIngested} new, ${lastRun.zipsDuplicate} skipped, ${lastRun.messagesScanned} messages`}
|
||||
</span>
|
||||
</>
|
||||
) : hasAuthenticated ? (
|
||||
<>
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Waiting for first sync...
|
||||
</span>
|
||||
</>
|
||||
) : accounts.some((a) => a.authState === "PENDING") ? (
|
||||
<>
|
||||
<Clock className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Pending account detected — worker will send an SMS code on the next cycle. Please wait...
|
||||
</span>
|
||||
</>
|
||||
) : accounts.some(
|
||||
(a) => a.authState === "AWAITING_CODE" || a.authState === "AWAITING_PASSWORD"
|
||||
) ? (
|
||||
<>
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-orange-500 shrink-0" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Waiting for you to enter the auth code — check the Accounts table below
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Radio className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Worker idle — authenticate an account to start syncing
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{nextRunCountdown && hasAuthenticated && (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<RefreshCw className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
Next: {nextRunCountdown}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTimeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
Reference in New Issue
Block a user