Repository Public
1"use client";2import { useState } from "react";3import { useLocale, useT } from "@/i18n";4import { adminConfig } from "@/i18n/admin-config";5import { api } from "@/lib/client-api";6import { fmtDateTime } from "@/lib/format";7import { Badge, Button, Empty, errorText, Field, Notice, toast } from "@/components/ui";8import { Loadable, useAdmin, useLoad } from "@/components/admin/shell";9import { Confirm, CopyButton, Section, Toggle } from "./common";1011interface Cal { id: string; name: string; provider: string; status: string; blocksAvailability: boolean; lastSuccessAt: string | null; lastAttemptAt: string | null; lastError: string | null; events: number; stale: boolean }12interface Data { calendars: Cal[]; feedEnabled: boolean; staleMinutes: number; stalePolicy: "conservative" | "ignore"; connectors: Record<string, string> }13interface SyncOutcome { ok: boolean; events: number; error?: string; notModified?: boolean }1415export function IntegrationsTab() {16 const t = useT(adminConfig);17 const locale = useLocale();18 const { timezone } = useAdmin();19 const state = useLoad<Data>("/api/v1/admin/calendars");20 const [label, setLabel] = useState("");21 const [url, setUrl] = useState("");22 const [blocks, setBlocks] = useState(true);23 const [adding, setAdding] = useState(false);24 const [addResult, setAddResult] = useState<SyncOutcome | null>(null);25 const [addError, setAddError] = useState<string | null>(null);26 const [busy, setBusy] = useState<string | null>(null);27 const [names, setNames] = useState<Record<string, string>>({});28 const [toRemove, setToRemove] = useState<Cal | null>(null);29 const [feedUrl, setFeedUrl] = useState<string | null>(null);30 const [feedConfirm, setFeedConfirm] = useState<"rotate" | "disable" | null>(null);31 const d = state.data;3233 const add = async (ev: React.FormEvent) => {34 ev.preventDefault(); setAddError(null); setAddResult(null);35 if (!/^(https|webcal):\/\//i.test(url.trim())) { setAddError(t("igErrUrl")); return; }36 setAdding(true);37 try {38 const r = await api<{ id: string; sync: SyncOutcome }>("/api/v1/admin/calendars", { body: { label: label.trim(), url: url.trim(), blocksAvailability: blocks } });39 setAddResult(r.sync); setLabel(""); setUrl(""); await state.reload();40 } catch (e) { setAddError(errorText(e, locale)); } finally { setAdding(false); }41 };42 const run = async (key: string, fn: () => Promise<void>) => { setBusy(key); try { await fn(); } catch (e) { toast.error(errorText(e, locale)); } finally { setBusy(null); } };43 const patch = (c: Cal, next: { name?: string; blocksAvailability?: boolean }) => run(`p${c.id}`, async () => {44 await api(`/api/v1/admin/calendars/${c.id}`, { method: "PATCH", body: { name: next.name ?? c.name, blocksAvailability: next.blocksAvailability ?? c.blocksAvailability } });45 toast.ok(t("igSaved")); await state.reload();46 });47 const sync = (c: Cal) => run(`s${c.id}`, async () => {48 const r = await api<{ sync: SyncOutcome }>(`/api/v1/admin/calendars/${c.id}/sync`, { method: "POST", body: {} });49 if (r.sync.ok) toast.ok(r.sync.notModified ? t("igSyncUnchanged") : t("igSyncOk", { n: r.sync.events })); else toast.error(t("igSyncFailed", { error: r.sync.error ?? "" }));50 await state.reload();51 });52 const remove = () => toRemove && run("remove", async () => { await api(`/api/v1/admin/calendars/${toRemove.id}`, { method: "DELETE" }); toast.ok(t("igRemoved")); setToRemove(null); await state.reload(); });53 const rotateFeed = () => run("feed", async () => {54 const r = await api<{ token: string }>("/api/v1/admin/feed", { method: "POST", body: {} });55 setFeedUrl(`${window.location.origin}/api/v1/public/feed/${r.token}.ics`); setFeedConfirm(null); await state.reload();56 });57 const disableFeed = () => run("feed", async () => { await api("/api/v1/admin/feed", { method: "DELETE" }); setFeedUrl(null); setFeedConfirm(null); toast.ok(t("igFeedDisabled")); await state.reload(); });5859 const connectors: { key: string; name: string }[] = [{ key: "google", name: "Google Calendar" }, { key: "microsoft", name: "Microsoft 365 / Outlook" }, { key: "caldav", name: "CalDAV (Apple iCloud…)" }];6061 return (62 <Loadable state={state}>63 {d ? (64 <div className="grid gap-5">65 <Section title={t("igIcsTitle")} lead={t("igIcsLead")}>66 {d.calendars.length === 0 ? <Empty icon="calendar" title={t("igEmpty")}>{t("igEmptyLead")}</Empty> : (67 <ul className="grid gap-3">68 {d.calendars.map((c) => {69 const name = names[c.id] ?? c.name;70 return (71 <li key={c.id} className="rounded-2xl border border-line p-4 grid gap-3">72 <div className="flex flex-wrap items-center gap-2">73 <span className="font-semibold break-words">{c.name}</span>74 <Badge tone={c.status === "connected" ? "ok" : "danger"}>{c.status === "connected" ? t("igStConnected") : t("igStError")}</Badge>75 {c.stale ? <Badge tone="pending">{t("igStale")}</Badge> : <Badge tone="info">{t("igFresh")}</Badge>}76 </div>77 <p className="t-mute text-sm flex flex-wrap gap-x-4 gap-y-0.5">78 <span>{c.lastSuccessAt ? t("igLastSuccess", { date: fmtDateTime(c.lastSuccessAt, timezone, locale) }) : t("igNeverSynced")}</span>79 {c.lastAttemptAt ? <span>{t("igLastAttempt", { date: fmtDateTime(c.lastAttemptAt, timezone, locale) })}</span> : null}80 <span>{t("igEvents", { n: c.events })}</span>81 </p>82 {c.lastError ? <Notice tone="danger"><span className="break-words">{t("igLastError")} {c.lastError}</span></Notice> : null}83 <Toggle checked={c.blocksAvailability} disabled={busy === `p${c.id}`} label={t("igBlocks")} hint={c.blocksAvailability ? t("igBlocksOn") : t("igBlocksOff")} onChange={(v) => patch(c, { blocksAvailability: v })} />84 <div className="flex flex-wrap items-end gap-2">85 <div className="flex-1 min-w-[180px] max-w-xs"><Field label={t("igName")}>{(p) => <input {...p} className="input" value={name} maxLength={80} onChange={(e) => setNames({ ...names, [c.id]: e.target.value })} />}</Field></div>86 <Button small disabled={!name.trim() || name === c.name} busy={busy === `p${c.id}`} onClick={() => patch(c, { name: name.trim() })}>{t("igRename")}</Button>87 </div>88 <div className="flex flex-wrap gap-2 hairline pt-3">89 <Button small icon="refresh" busy={busy === `s${c.id}`} onClick={() => sync(c)}>{t("igSyncNow")}</Button>90 <Button small variant="ghost" icon="trash" onClick={() => setToRemove(c)}>{t("igDisconnect")}</Button>91 </div>92 </li>93 );94 })}95 </ul>96 )}97 <form onSubmit={add} className="grid gap-4 hairline mt-5 pt-5">98 <h3 className="t-eyebrow">{t("igAddTitle")}</h3>99 <div className="grid sm:grid-cols-[1fr_2fr] gap-4">100 <Field label={t("igLabel")}>{(p) => <input {...p} className="input" required value={label} maxLength={80} placeholder={t("igLabelPh")} onChange={(e) => setLabel(e.target.value)} />}</Field>101 <Field label={t("igUrl")} hint={t("igUrlHint")}>{(p) => <input {...p} className="input font-mono !text-[14px]" required type="text" inputMode="url" autoCapitalize="none" spellCheck={false} value={url} maxLength={2000} placeholder="https://… / webcal://…" onChange={(e) => setUrl(e.target.value)} />}</Field>102 </div>103 <Toggle checked={blocks} label={t("igBlocks")} hint={t("igBlocksAddHint")} onChange={setBlocks} />104 {addError ? <Notice tone="danger">{addError}</Notice> : null}105 {addResult ? (addResult.ok ? <Notice tone="ok">{t("igAddOk", { n: addResult.events })}</Notice> : <Notice tone="warn">{t("igAddFailed", { error: addResult.error ?? "" })}</Notice>) : null}106 <div><Button type="submit" variant="primary" icon="plus" busy={adding}>{t("igAdd")}</Button></div>107 </form>108 <p className="hint">{t("igStaleExplain", { n: d.staleMinutes, policy: d.stalePolicy === "conservative" ? t("stStaleConservative") : t("stStaleIgnore") })} <a className="underline" href="/admin/settings?tab=profile">{t("igStaleLink")}</a></p>109 </Section>110111 <Section title={t("igOtherTitle")} lead={t("igOtherLead")}>112 <ul className="grid gap-2">113 {connectors.map((c) => (114 <li key={c.key} className="rounded-2xl border border-line p-3 flex flex-wrap items-center justify-between gap-2">115 <span className="font-medium t-mute">{c.name}</span>116 <Badge>{t("igNotImplemented")}</Badge>117 </li>118 ))}119 </ul>120 </Section>121122 <Section title={t("igFeedTitle")} lead={t("igFeedLead")}>123 <div className="grid gap-3">124 <div className="flex flex-wrap items-center gap-2"><Badge tone={d.feedEnabled ? "ok" : "mute"}>{d.feedEnabled ? t("igFeedOn") : t("igFeedOff")}</Badge></div>125 {feedUrl ? (126 <Notice tone="warn">127 <p className="font-semibold">{t("igFeedOnce")}</p>128 <p className="font-mono text-[13px] break-all my-2 select-all">{feedUrl}</p>129 <CopyButton text={feedUrl} label={t("cCopyLink")} copied={t("cCopied")} />130 </Notice>131 ) : null}132 <div className="flex flex-wrap gap-2">133 {d.feedEnabled134 ? <><Button small busy={busy === "feed"} onClick={() => setFeedConfirm("rotate")}>{t("igFeedRotate")}</Button><Button small variant="ghost" onClick={() => setFeedConfirm("disable")}>{t("igFeedDisable")}</Button></>135 : <Button small variant="primary" busy={busy === "feed"} onClick={rotateFeed}>{t("igFeedEnable")}</Button>}136 </div>137 <p className="hint !mt-0">{t("igFeedHint")}</p>138 </div>139 </Section>140141 <Confirm open={!!toRemove} onClose={() => setToRemove(null)} title={t("igDisconnectTitle")} confirmLabel={t("igDisconnect")} cancelLabel={t("cCancel")} onConfirm={remove} busy={busy === "remove"}>142 <p>{t("igDisconnectLead", { name: toRemove?.name ?? "" })}</p>143 </Confirm>144 <Confirm open={!!feedConfirm} onClose={() => setFeedConfirm(null)} title={feedConfirm === "rotate" ? t("igFeedRotate") : t("igFeedDisable")} confirmLabel={feedConfirm === "rotate" ? t("igFeedRotate") : t("igFeedDisable")} cancelLabel={t("cCancel")}145 onConfirm={feedConfirm === "rotate" ? rotateFeed : disableFeed} busy={busy === "feed"}>146 <p>{t("igFeedConfirm")}</p>147 </Confirm>148 </div>149 ) : null}150 </Loadable>151 );152}153