SPB Git

Repository Public

settings-integrations.tsx 10.8 KB 153 lines tsx

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
Files main
Menu

Theme