diff --git a/apps/web/package.json b/apps/web/package.json index ef198e0..c4a377f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,6 +18,7 @@ "@fontsource-variable/funnel-display": "^5.3.0", "@fontsource-variable/geologica": "^5.3.0", "@fontsource-variable/inter": "^5.3.0", + "@modelcontextprotocol/sdk": "^1.30.0", "@radix-ui/react-popover": "^1.1.23", "@tanstack/react-query": "^5.90.2", "@trpc/client": "^11.4.3", @@ -46,7 +47,8 @@ "superjson": "^2.2.2", "tailwind-merge": "^3.3.0", "tw-animate-css": "^1.4.0", - "zod": "^3.25.67" + "zod": "^3.25.67", + "zod-to-json-schema": "^3.25.2" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.10", diff --git a/apps/web/src/app/api/health/ready/route.ts b/apps/web/src/app/api/health/ready/route.ts new file mode 100644 index 0000000..0d27edd --- /dev/null +++ b/apps/web/src/app/api/health/ready/route.ts @@ -0,0 +1,10 @@ +import { databaseReady } from "@album/database"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export async function GET() { + const ready = await databaseReady(); + return Response.json({ status: ready ? "ready" : "unavailable" }, { + status: ready ? 200 : 503, headers: { "Cache-Control": "no-store" }, + }); +} diff --git a/apps/web/src/app/api/mcp/route.ts b/apps/web/src/app/api/mcp/route.ts new file mode 100644 index 0000000..6852432 --- /dev/null +++ b/apps/web/src/app/api/mcp/route.ts @@ -0,0 +1,6 @@ +import { handleMcp } from "@/server/mcp/server"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const POST = handleMcp; +export const GET = handleMcp; +export const DELETE = handleMcp; diff --git a/apps/web/src/app/dashboard/assistants/connections.tsx b/apps/web/src/app/dashboard/assistants/connections.tsx new file mode 100644 index 0000000..1133577 --- /dev/null +++ b/apps/web/src/app/dashboard/assistants/connections.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useState } from "react"; +import { CopyIcon, KeyRoundIcon } from "lucide-react"; +import { toast } from "sonner"; +import { api } from "@/trpc/react"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Field, FieldGroup, FieldLabel, FieldDescription } from "@/components/ui/field"; +import { Switch } from "@/components/ui/switch"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +export function AssistantConnections({ endpoint }: { endpoint: string }) { + const utils = api.useUtils(); + const options = api.assistants.options.useQuery(); + const tokens = api.assistants.list.useQuery(); + const [name, setName] = useState(""); + const [days, setDays] = useState(30); + const [readOnly, setReadOnly] = useState(true); + const [selected, setSelected] = useState(null); + const [secret, setSecret] = useState(null); + const permissions = selected ?? options.data?.defaults ?? []; + const create = api.assistants.create.useMutation({ onSuccess: async result => { + setSecret(result.token); create.reset(); setName(""); await utils.assistants.list.invalidate(); toast.success("Assistant token created"); + }, onError: error => toast.error(error.message) }); + const revoke = api.assistants.revoke.useMutation({ onSuccess: async () => { await utils.assistants.list.invalidate(); toast.success("Token revoked"); }, onError: error => toast.error(error.message) }); + async function copy(value: string) { try { await navigator.clipboard.writeText(value); toast.success("Copied"); } catch { toast.error("Could not copy; select and copy the text manually."); } } + return <> + MCP endpointStreamable HTTP with an Authorization: Bearer token header. No browser cookies or shared administrator key. + + {secret ? Save your token nowThis is the only time it can be shown. Store it in your assistant's secret manager; never paste it into prompts. +
: null} + Create a connectionSelected permissions are a ceiling, not a new role. Your current account permissions are checked for every action. +
{ event.preventDefault(); if (!create.isPending) create.mutate({ name, days, readOnly, permissions }); }}> + Connection name setName(event.target.value)} placeholder="My assistant" required maxLength={80} /> + Expires after (days) setDays(Number(event.target.value))} /> + Read-only access + Turn off read-only only for assistants allowed to make changes, send emails, or delete data. Write tools also require an explicit confirmation argument. + + {!readOnly ? This token can perform changes permitted by the selections below and your current roles. Only give it to an assistant you trust. : null} +
Permissions · {permissions.length} selected + {options.data?.permissions.map(permission => {permission} setSelected(checked ? [...permissions, permission] : permissions.filter(value => value !== permission))} />)} +
+ {options.isError ?

Permissions could not load. Refresh to retry.

: null} + +
+ Your connectionsRevocation blocks subsequent requests immediately. Requests already executing may finish. + {tokens.isLoading ?

Loading connections…

: tokens.isError ?

Connections could not load.

: !tokens.data?.length ?

No assistant connections yet.

: tokens.data.map(token =>
+

{token.name}

{token.revokedAt ? "Revoked" : token.expiresAt < new Date() ? "Expired" : token.readOnly ? "Read-only" : "Read and write"} · expires {token.expiresAt.toLocaleDateString()} · {token.permissions.length} permissions

{token.lastUsedAt ? `Last used ${token.lastUsedAt.toLocaleString()}` : "Not used yet"}

+ {!token.revokedAt ? : null} +
)}
+ ; +} diff --git a/apps/web/src/app/dashboard/assistants/page.tsx b/apps/web/src/app/dashboard/assistants/page.tsx new file mode 100644 index 0000000..67e4ab9 --- /dev/null +++ b/apps/web/src/app/dashboard/assistants/page.tsx @@ -0,0 +1,10 @@ +import { AssistantConnections } from "./connections"; +import { publicAppOrigin } from "@/server/public-app-url"; + +export default function AssistantsPage() { + return
+

Assistant connections

+

Connect assistants through MCP with your existing event, group, and platform permissions.

+ +
; +} diff --git a/apps/web/src/app/dashboard/events/[id]/moderation-grid.tsx b/apps/web/src/app/dashboard/events/[id]/moderation-grid.tsx index 0002739..b933626 100644 --- a/apps/web/src/app/dashboard/events/[id]/moderation-grid.tsx +++ b/apps/web/src/app/dashboard/events/[id]/moderation-grid.tsx @@ -70,8 +70,8 @@ export function ModerationGrid({ onError: (error) => toast.error(error.message), }); const moderateSubmission = api.manager.moderateSubmission.useMutation({ - onSuccess: async () => { - toast.success("Submission updated"); + onSuccess: async (result) => { + toast.success(`${result.updatedCount} photos updated`); await utils.manager.photos.invalidate({ eventId }); }, onError: (error) => toast.error(error.message), @@ -211,9 +211,15 @@ export function ModerationGrid({ ) : null} - {canModerate && photo.processingStatus === "ready" ? ( + {canModerate ? ( moderateSubmission.mutate({ submissionId: photo.submissionId, visibility: "hidden" })}> + onSelect={() => moderateSubmission.mutate({ eventId, submissionId: photo.submissionId, visibility: "public" })}> + + ) : null} + {canModerate ? ( + moderateSubmission.mutate({ eventId, submissionId: photo.submissionId, visibility: "hidden" })}> ) : null} diff --git a/apps/web/src/app/dashboard/people/page.tsx b/apps/web/src/app/dashboard/people/page.tsx index 3a38597..578799d 100644 --- a/apps/web/src/app/dashboard/people/page.tsx +++ b/apps/web/src/app/dashboard/people/page.tsx @@ -17,7 +17,7 @@ export default async function DashboardPeoplePage() { No group yet - Create an event to start a group. + Use Create group in the header to start a workspace for your team. ); diff --git a/apps/web/src/app/e/[slug]/guest-upload.tsx b/apps/web/src/app/e/[slug]/guest-upload.tsx index 946cb1e..0f49362 100644 --- a/apps/web/src/app/e/[slug]/guest-upload.tsx +++ b/apps/web/src/app/e/[slug]/guest-upload.tsx @@ -17,6 +17,8 @@ import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import { Progress } from "@/components/ui/progress"; import { Spinner } from "@/components/ui/spinner"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; +import { uploadProgress } from "@/lib/upload-progress"; type QueueItem = { file?: File; @@ -59,6 +61,7 @@ export function GuestUpload({ const retryUpload = api.photos.retryUpload.useMutation(); const uploading = useRef(false); const utils = api.useUtils(); + const progress = uploadProgress(queue); useEffect(() => { const storedName = localStorage.getItem(guestKey(slug, "name")) ?? ""; @@ -257,7 +260,20 @@ export function GuestUpload({ : null} {queue.length > 0 ? ( -