diff --git a/src/app/route-tree.gen.ts b/src/app/route-tree.gen.ts index 1e788bbc..76219431 100644 --- a/src/app/route-tree.gen.ts +++ b/src/app/route-tree.gen.ts @@ -23,7 +23,6 @@ import { Route as With_menuSportRouteImport } from "./routes/_with_menu/sport"; import { Route as With_menuScholarshipRouteImport } from "./routes/_with_menu/scholarship"; import { Route as With_menuMenuRouteImport } from "./routes/_with_menu/menu"; import { Route as With_menuMapsRouteImport } from "./routes/_with_menu/maps"; -import { Route as With_menuGuardRouteImport } from "./routes/_with_menu/guard"; import { Route as With_menuFormsRouteImport } from "./routes/_with_menu/forms"; import { Route as With_menuExtensionRouteImport } from "./routes/_with_menu/extension"; import { Route as With_menuDormsRouteImport } from "./routes/_with_menu/dorms"; @@ -39,6 +38,7 @@ import { Route as With_menuScheduleIndexRouteImport } from "./routes/_with_menu/ import { Route as With_menuRoomBookingIndexRouteImport } from "./routes/_with_menu/room-booking/index"; import { Route as With_menuPrintersIndexRouteImport } from "./routes/_with_menu/printers/index"; import { Route as With_menuMusicRoomIndexRouteImport } from "./routes/_with_menu/music-room.index"; +import { Route as With_menuGuardIndexRouteImport } from "./routes/_with_menu/guard/index"; import { Route as With_menuEventsIndexRouteImport } from "./routes/_with_menu/events/index"; import { Route as With_menuClubsIndexRouteImport } from "./routes/_with_menu/clubs/index"; import { Route as With_menuCatalogueIndexRouteImport } from "./routes/_with_menu/catalogue/index"; @@ -55,6 +55,9 @@ import { Route as With_menuRoomBookingListRouteImport } from "./routes/_with_men import { Route as With_menuPrintersScanRouteImport } from "./routes/_with_menu/printers/scan"; import { Route as With_menuPrintersPrintRouteImport } from "./routes/_with_menu/printers/print"; import { Route as With_menuMusicRoomInstructionsRouteImport } from "./routes/_with_menu/music-room.instructions"; +import { Route as With_menuGuardFilesRouteImport } from "./routes/_with_menu/guard/files"; +import { Route as With_menuGuardCreateRouteImport } from "./routes/_with_menu/guard/create"; +import { Route as With_menuGuardCopyRouteImport } from "./routes/_with_menu/guard/copy"; import { Route as With_menuEventsCalendarRouteImport } from "./routes/_with_menu/events/calendar"; import { Route as With_menuEventsArchiveRouteImport } from "./routes/_with_menu/events/archive"; import { Route as With_menuEventsApprovalRouteImport } from "./routes/_with_menu/events/approval"; @@ -145,11 +148,6 @@ const With_menuMapsRoute = With_menuMapsRouteImport.update({ path: "/maps", getParentRoute: () => With_menuRouteRoute, } as any); -const With_menuGuardRoute = With_menuGuardRouteImport.update({ - id: "/guard", - path: "/guard", - getParentRoute: () => With_menuRouteRoute, -} as any); const With_menuFormsRoute = With_menuFormsRouteImport.update({ id: "/forms", path: "/forms", @@ -228,6 +226,11 @@ const With_menuMusicRoomIndexRoute = With_menuMusicRoomIndexRouteImport.update({ path: "/music-room/", getParentRoute: () => With_menuRouteRoute, } as any); +const With_menuGuardIndexRoute = With_menuGuardIndexRouteImport.update({ + id: "/guard/", + path: "/guard/", + getParentRoute: () => With_menuRouteRoute, +} as any); const With_menuEventsIndexRoute = With_menuEventsIndexRouteImport.update({ id: "/events/", path: "/events/", @@ -315,6 +318,21 @@ const With_menuMusicRoomInstructionsRoute = path: "/music-room/instructions", getParentRoute: () => With_menuRouteRoute, } as any); +const With_menuGuardFilesRoute = With_menuGuardFilesRouteImport.update({ + id: "/guard/files", + path: "/guard/files", + getParentRoute: () => With_menuRouteRoute, +} as any); +const With_menuGuardCreateRoute = With_menuGuardCreateRouteImport.update({ + id: "/guard/create", + path: "/guard/create", + getParentRoute: () => With_menuRouteRoute, +} as any); +const With_menuGuardCopyRoute = With_menuGuardCopyRouteImport.update({ + id: "/guard/copy", + path: "/guard/copy", + getParentRoute: () => With_menuRouteRoute, +} as any); const With_menuEventsCalendarRoute = With_menuEventsCalendarRouteImport.update({ id: "/events/calendar", path: "/events/calendar", @@ -428,7 +446,6 @@ export interface FileRoutesByFullPath { "/dorms": typeof With_menuDormsRoute; "/extension": typeof With_menuExtensionRoute; "/forms": typeof With_menuFormsRoute; - "/guard": typeof With_menuGuardRoute; "/maps": typeof With_menuMapsRoute; "/menu": typeof With_menuMenuRoute; "/scholarship": typeof With_menuScholarshipRoute; @@ -449,6 +466,9 @@ export interface FileRoutesByFullPath { "/events/approval": typeof With_menuEventsApprovalRoute; "/events/archive": typeof With_menuEventsArchiveRoute; "/events/calendar": typeof With_menuEventsCalendarRoute; + "/guard/copy": typeof With_menuGuardCopyRoute; + "/guard/create": typeof With_menuGuardCreateRoute; + "/guard/files": typeof With_menuGuardFilesRoute; "/music-room/instructions": typeof With_menuMusicRoomInstructionsRoute; "/printers/print": typeof With_menuPrintersPrintRoute; "/printers/scan": typeof With_menuPrintersScanRoute; @@ -465,6 +485,7 @@ export interface FileRoutesByFullPath { "/catalogue/": typeof With_menuCatalogueIndexRoute; "/clubs/": typeof With_menuClubsIndexRoute; "/events/": typeof With_menuEventsIndexRoute; + "/guard/": typeof With_menuGuardIndexRoute; "/music-room/": typeof With_menuMusicRoomIndexRoute; "/printers/": typeof With_menuPrintersIndexRoute; "/room-booking/": typeof With_menuRoomBookingIndexRoute; @@ -493,7 +514,6 @@ export interface FileRoutesByTo { "/dorms": typeof With_menuDormsRoute; "/extension": typeof With_menuExtensionRoute; "/forms": typeof With_menuFormsRoute; - "/guard": typeof With_menuGuardRoute; "/maps": typeof With_menuMapsRoute; "/menu": typeof With_menuMenuRoute; "/scholarship": typeof With_menuScholarshipRoute; @@ -514,6 +534,9 @@ export interface FileRoutesByTo { "/events/approval": typeof With_menuEventsApprovalRoute; "/events/archive": typeof With_menuEventsArchiveRoute; "/events/calendar": typeof With_menuEventsCalendarRoute; + "/guard/copy": typeof With_menuGuardCopyRoute; + "/guard/create": typeof With_menuGuardCreateRoute; + "/guard/files": typeof With_menuGuardFilesRoute; "/music-room/instructions": typeof With_menuMusicRoomInstructionsRoute; "/printers/print": typeof With_menuPrintersPrintRoute; "/printers/scan": typeof With_menuPrintersScanRoute; @@ -530,6 +553,7 @@ export interface FileRoutesByTo { "/catalogue": typeof With_menuCatalogueIndexRoute; "/clubs": typeof With_menuClubsIndexRoute; "/events": typeof With_menuEventsIndexRoute; + "/guard": typeof With_menuGuardIndexRoute; "/music-room": typeof With_menuMusicRoomIndexRoute; "/printers": typeof With_menuPrintersIndexRoute; "/room-booking": typeof With_menuRoomBookingIndexRoute; @@ -561,7 +585,6 @@ export interface FileRoutesById { "/_with_menu/dorms": typeof With_menuDormsRoute; "/_with_menu/extension": typeof With_menuExtensionRoute; "/_with_menu/forms": typeof With_menuFormsRoute; - "/_with_menu/guard": typeof With_menuGuardRoute; "/_with_menu/maps": typeof With_menuMapsRoute; "/_with_menu/menu": typeof With_menuMenuRoute; "/_with_menu/scholarship": typeof With_menuScholarshipRoute; @@ -582,6 +605,9 @@ export interface FileRoutesById { "/_with_menu/events/approval": typeof With_menuEventsApprovalRoute; "/_with_menu/events/archive": typeof With_menuEventsArchiveRoute; "/_with_menu/events/calendar": typeof With_menuEventsCalendarRoute; + "/_with_menu/guard/copy": typeof With_menuGuardCopyRoute; + "/_with_menu/guard/create": typeof With_menuGuardCreateRoute; + "/_with_menu/guard/files": typeof With_menuGuardFilesRoute; "/_with_menu/music-room/instructions": typeof With_menuMusicRoomInstructionsRoute; "/_with_menu/printers/print": typeof With_menuPrintersPrintRoute; "/_with_menu/printers/scan": typeof With_menuPrintersScanRoute; @@ -598,6 +624,7 @@ export interface FileRoutesById { "/_with_menu/catalogue/": typeof With_menuCatalogueIndexRoute; "/_with_menu/clubs/": typeof With_menuClubsIndexRoute; "/_with_menu/events/": typeof With_menuEventsIndexRoute; + "/_with_menu/guard/": typeof With_menuGuardIndexRoute; "/_with_menu/music-room/": typeof With_menuMusicRoomIndexRoute; "/_with_menu/printers/": typeof With_menuPrintersIndexRoute; "/_with_menu/room-booking/": typeof With_menuRoomBookingIndexRoute; @@ -629,7 +656,6 @@ export interface FileRouteTypes { | "/dorms" | "/extension" | "/forms" - | "/guard" | "/maps" | "/menu" | "/scholarship" @@ -650,6 +676,9 @@ export interface FileRouteTypes { | "/events/approval" | "/events/archive" | "/events/calendar" + | "/guard/copy" + | "/guard/create" + | "/guard/files" | "/music-room/instructions" | "/printers/print" | "/printers/scan" @@ -666,6 +695,7 @@ export interface FileRouteTypes { | "/catalogue/" | "/clubs/" | "/events/" + | "/guard/" | "/music-room/" | "/printers/" | "/room-booking/" @@ -694,7 +724,6 @@ export interface FileRouteTypes { | "/dorms" | "/extension" | "/forms" - | "/guard" | "/maps" | "/menu" | "/scholarship" @@ -715,6 +744,9 @@ export interface FileRouteTypes { | "/events/approval" | "/events/archive" | "/events/calendar" + | "/guard/copy" + | "/guard/create" + | "/guard/files" | "/music-room/instructions" | "/printers/print" | "/printers/scan" @@ -731,6 +763,7 @@ export interface FileRouteTypes { | "/catalogue" | "/clubs" | "/events" + | "/guard" | "/music-room" | "/printers" | "/room-booking" @@ -761,7 +794,6 @@ export interface FileRouteTypes { | "/_with_menu/dorms" | "/_with_menu/extension" | "/_with_menu/forms" - | "/_with_menu/guard" | "/_with_menu/maps" | "/_with_menu/menu" | "/_with_menu/scholarship" @@ -782,6 +814,9 @@ export interface FileRouteTypes { | "/_with_menu/events/approval" | "/_with_menu/events/archive" | "/_with_menu/events/calendar" + | "/_with_menu/guard/copy" + | "/_with_menu/guard/create" + | "/_with_menu/guard/files" | "/_with_menu/music-room/instructions" | "/_with_menu/printers/print" | "/_with_menu/printers/scan" @@ -798,6 +833,7 @@ export interface FileRouteTypes { | "/_with_menu/catalogue/" | "/_with_menu/clubs/" | "/_with_menu/events/" + | "/_with_menu/guard/" | "/_with_menu/music-room/" | "/_with_menu/printers/" | "/_with_menu/room-booking/" @@ -927,13 +963,6 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof With_menuMapsRouteImport; parentRoute: typeof With_menuRouteRoute; }; - "/_with_menu/guard": { - id: "/_with_menu/guard"; - path: "/guard"; - fullPath: "/guard"; - preLoaderRoute: typeof With_menuGuardRouteImport; - parentRoute: typeof With_menuRouteRoute; - }; "/_with_menu/forms": { id: "/_with_menu/forms"; path: "/forms"; @@ -1039,6 +1068,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof With_menuMusicRoomIndexRouteImport; parentRoute: typeof With_menuRouteRoute; }; + "/_with_menu/guard/": { + id: "/_with_menu/guard/"; + path: "/guard"; + fullPath: "/guard/"; + preLoaderRoute: typeof With_menuGuardIndexRouteImport; + parentRoute: typeof With_menuRouteRoute; + }; "/_with_menu/events/": { id: "/_with_menu/events/"; path: "/events"; @@ -1151,6 +1187,27 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof With_menuMusicRoomInstructionsRouteImport; parentRoute: typeof With_menuRouteRoute; }; + "/_with_menu/guard/files": { + id: "/_with_menu/guard/files"; + path: "/guard/files"; + fullPath: "/guard/files"; + preLoaderRoute: typeof With_menuGuardFilesRouteImport; + parentRoute: typeof With_menuRouteRoute; + }; + "/_with_menu/guard/create": { + id: "/_with_menu/guard/create"; + path: "/guard/create"; + fullPath: "/guard/create"; + preLoaderRoute: typeof With_menuGuardCreateRouteImport; + parentRoute: typeof With_menuRouteRoute; + }; + "/_with_menu/guard/copy": { + id: "/_with_menu/guard/copy"; + path: "/guard/copy"; + fullPath: "/guard/copy"; + preLoaderRoute: typeof With_menuGuardCopyRouteImport; + parentRoute: typeof With_menuRouteRoute; + }; "/_with_menu/events/calendar": { id: "/_with_menu/events/calendar"; path: "/events/calendar"; @@ -1295,7 +1352,6 @@ interface With_menuRouteRouteChildren { With_menuDormsRoute: typeof With_menuDormsRoute; With_menuExtensionRoute: typeof With_menuExtensionRoute; With_menuFormsRoute: typeof With_menuFormsRoute; - With_menuGuardRoute: typeof With_menuGuardRoute; With_menuMapsRoute: typeof With_menuMapsRoute; With_menuMenuRoute: typeof With_menuMenuRoute; With_menuScholarshipRoute: typeof With_menuScholarshipRoute; @@ -1311,6 +1367,9 @@ interface With_menuRouteRouteChildren { With_menuEventsApprovalRoute: typeof With_menuEventsApprovalRoute; With_menuEventsArchiveRoute: typeof With_menuEventsArchiveRoute; With_menuEventsCalendarRoute: typeof With_menuEventsCalendarRoute; + With_menuGuardCopyRoute: typeof With_menuGuardCopyRoute; + With_menuGuardCreateRoute: typeof With_menuGuardCreateRoute; + With_menuGuardFilesRoute: typeof With_menuGuardFilesRoute; With_menuMusicRoomInstructionsRoute: typeof With_menuMusicRoomInstructionsRoute; With_menuPrintersPrintRoute: typeof With_menuPrintersPrintRoute; With_menuPrintersScanRoute: typeof With_menuPrintersScanRoute; @@ -1326,6 +1385,7 @@ interface With_menuRouteRouteChildren { With_menuCatalogueIndexRoute: typeof With_menuCatalogueIndexRoute; With_menuClubsIndexRoute: typeof With_menuClubsIndexRoute; With_menuEventsIndexRoute: typeof With_menuEventsIndexRoute; + With_menuGuardIndexRoute: typeof With_menuGuardIndexRoute; With_menuMusicRoomIndexRoute: typeof With_menuMusicRoomIndexRoute; With_menuPrintersIndexRoute: typeof With_menuPrintersIndexRoute; With_menuRoomBookingIndexRoute: typeof With_menuRoomBookingIndexRoute; @@ -1352,7 +1412,6 @@ const With_menuRouteRouteChildren: With_menuRouteRouteChildren = { With_menuDormsRoute: With_menuDormsRoute, With_menuExtensionRoute: With_menuExtensionRoute, With_menuFormsRoute: With_menuFormsRoute, - With_menuGuardRoute: With_menuGuardRoute, With_menuMapsRoute: With_menuMapsRoute, With_menuMenuRoute: With_menuMenuRoute, With_menuScholarshipRoute: With_menuScholarshipRoute, @@ -1368,6 +1427,9 @@ const With_menuRouteRouteChildren: With_menuRouteRouteChildren = { With_menuEventsApprovalRoute: With_menuEventsApprovalRoute, With_menuEventsArchiveRoute: With_menuEventsArchiveRoute, With_menuEventsCalendarRoute: With_menuEventsCalendarRoute, + With_menuGuardCopyRoute: With_menuGuardCopyRoute, + With_menuGuardCreateRoute: With_menuGuardCreateRoute, + With_menuGuardFilesRoute: With_menuGuardFilesRoute, With_menuMusicRoomInstructionsRoute: With_menuMusicRoomInstructionsRoute, With_menuPrintersPrintRoute: With_menuPrintersPrintRoute, With_menuPrintersScanRoute: With_menuPrintersScanRoute, @@ -1383,6 +1445,7 @@ const With_menuRouteRouteChildren: With_menuRouteRouteChildren = { With_menuCatalogueIndexRoute: With_menuCatalogueIndexRoute, With_menuClubsIndexRoute: With_menuClubsIndexRoute, With_menuEventsIndexRoute: With_menuEventsIndexRoute, + With_menuGuardIndexRoute: With_menuGuardIndexRoute, With_menuMusicRoomIndexRoute: With_menuMusicRoomIndexRoute, With_menuPrintersIndexRoute: With_menuPrintersIndexRoute, With_menuRoomBookingIndexRoute: With_menuRoomBookingIndexRoute, diff --git a/src/app/routes/_with_menu/guard/copy.tsx b/src/app/routes/_with_menu/guard/copy.tsx new file mode 100644 index 00000000..4f825cdf --- /dev/null +++ b/src/app/routes/_with_menu/guard/copy.tsx @@ -0,0 +1,30 @@ +import { RequireAuth } from "@/components/common/AuthWall.tsx"; +import { CopySheetPage } from "@/components/guard/CopySheetPage.tsx"; +import { GuardTabs } from "@/components/guard/GuardTabs.tsx"; +import { Topbar } from "@/components/layout/Topbar.tsx"; +import { Helmet } from "@dr.pogodin/react-helmet"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/_with_menu/guard/copy")({ + component: RouteComponent, +}); + +function RouteComponent() { + return ( + <> + + Copy sheet — Guard + + + + + + + + + + ); +} diff --git a/src/app/routes/_with_menu/guard/create.tsx b/src/app/routes/_with_menu/guard/create.tsx new file mode 100644 index 00000000..6c47ee48 --- /dev/null +++ b/src/app/routes/_with_menu/guard/create.tsx @@ -0,0 +1,30 @@ +import { RequireAuth } from "@/components/common/AuthWall.tsx"; +import { CreateSheetPage } from "@/components/guard/CreateSheetPage.tsx"; +import { GuardTabs } from "@/components/guard/GuardTabs.tsx"; +import { Topbar } from "@/components/layout/Topbar.tsx"; +import { Helmet } from "@dr.pogodin/react-helmet"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/_with_menu/guard/create")({ + component: RouteComponent, +}); + +function RouteComponent() { + return ( + <> + + Create sheet — Guard + + + + + + + + + + ); +} diff --git a/src/app/routes/_with_menu/guard.tsx b/src/app/routes/_with_menu/guard/files.tsx similarity index 51% rename from src/app/routes/_with_menu/guard.tsx rename to src/app/routes/_with_menu/guard/files.tsx index 85bf9fa1..dec41092 100644 --- a/src/app/routes/_with_menu/guard.tsx +++ b/src/app/routes/_with_menu/guard/files.tsx @@ -1,10 +1,11 @@ import { RequireAuth } from "@/components/common/AuthWall.tsx"; -import { GuardPage } from "@/components/guard/GuardPage.tsx"; +import { FilesPage } from "@/components/guard/FilesPage.tsx"; +import { GuardTabs } from "@/components/guard/GuardTabs.tsx"; import { Topbar } from "@/components/layout/Topbar.tsx"; -import { createFileRoute } from "@tanstack/react-router"; import { Helmet } from "@dr.pogodin/react-helmet"; +import { createFileRoute } from "@tanstack/react-router"; -export const Route = createFileRoute("/_with_menu/guard")({ +export const Route = createFileRoute("/_with_menu/guard/files")({ component: RouteComponent, }); @@ -12,12 +13,17 @@ function RouteComponent() { return ( <> - Guard + Your sheets — Guard + + - + ); diff --git a/src/app/routes/_with_menu/guard/index.tsx b/src/app/routes/_with_menu/guard/index.tsx new file mode 100644 index 00000000..a3106e18 --- /dev/null +++ b/src/app/routes/_with_menu/guard/index.tsx @@ -0,0 +1,27 @@ +import { GuardLandingPage } from "@/components/guard/GuardLandingPage.tsx"; +import { GuardTabs } from "@/components/guard/GuardTabs.tsx"; +import { Topbar } from "@/components/layout/Topbar.tsx"; +import { Helmet } from "@dr.pogodin/react-helmet"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/_with_menu/guard/")({ + component: RouteComponent, +}); + +function RouteComponent() { + return ( + <> + + Guard + + + + + + + + ); +} diff --git a/src/app/routes/guard.google.files.$slug.join.tsx b/src/app/routes/guard.google.files.$slug.join.tsx index 8b34d261..ceeaae4f 100644 --- a/src/app/routes/guard.google.files.$slug.join.tsx +++ b/src/app/routes/guard.google.files.$slug.join.tsx @@ -69,7 +69,6 @@ function RouteComponent() { <> Sign In - Join InNoHassle Guard File - {/* Do not scan this page */}
@@ -83,10 +82,9 @@ function RouteComponent() {
Join InNoHassle Guard File - {/* Do not scan this page */} -
+

Join InNoHassle Guard File

@@ -95,14 +93,9 @@ function RouteComponent() {

{!isSuccess ? ( -
-
- + +
+ Gmail Address setGmail(e.target.value)} placeholder="your.email@gmail.com" - className="border-base-content/20 bg-base-200/5 focus:border-primary focus:bg-base-200/10 rounded-field w-full border-2 px-3 py-3 text-base outline-hidden transition-colors" + className="input input-bordered w-full" disabled={isPending} /> -
+
) : null} - {isPending && ( -
-
- -
-

Adding you to the file...

-
- )} - {isSuccess && ( -
-
- ✅ Success! +
+
+

Success!

+

+ {gmail} has been added to the spreadsheet. +

+

+ Redirecting to spreadsheet in{" "} + {countdown} seconds... +

-

- {gmail} has been added to the spreadsheet. -

-

- Redirecting to spreadsheet in{" "} - {countdown} seconds... -

)} {error && ( -
- ❌ Error: -
- {errorMessage} +
+ + Error: {errorMessage} +
)}
diff --git a/src/components/guard/BannedList.tsx b/src/components/guard/BannedList.tsx index e5d7079f..14604fd4 100644 --- a/src/components/guard/BannedList.tsx +++ b/src/components/guard/BannedList.tsx @@ -1,6 +1,6 @@ -import { useState, useMemo } from "react"; -import { formatDate, filterByFields } from "./utils"; -import { MESSAGES } from "./consts"; +import { useToast } from "@/components/toast"; +import { useMemo, useState } from "react"; +import { filterByFields, formatDate } from "./utils"; type BannedItem = { user_id: string; @@ -9,26 +9,41 @@ type BannedItem = { banned_at: string; }; -interface BannedListProps { +export function BannedList({ + banned, + search, + onUnban, +}: { banned: BannedItem[]; search: string; onUnban: (userId: string) => Promise; -} - -export function BannedList({ banned, search, onUnban }: BannedListProps) { +}) { const filtered = useMemo( () => filterByFields(banned, search, ["gmail", "innomail"]), [banned, search], ); if (!banned || banned.length === 0) { - return
No banned users.
; + return ( +
+ +

No banned users

+
+ ); + } + + if (filtered.length === 0) { + return ( +

+ No banned users match your search +

+ ); } return ( -
+
{filtered.map((bannedUser) => ( - Promise; -} - -function BannedItem({ banned, onUnban }: BannedItemProps) { +}) { return ( -
+
-
+
- banned at {formatDate(banned.banned_at)} + banned {formatDate(banned.banned_at)}
- onUnban(banned.user_id)} /> + onUnban(banned.user_id)} + />
); } function Email({ email }: { email: string }) { + const [local, domain] = email.split("@"); return ( - - {email.split("@")[0]} - @{email.split("@")[1]} + + {local} + @{domain} ); } -function UnbanButton({ onClick }: { onClick: () => Promise }) { +function UnbanButton({ + onClick, + email, +}: { + onClick: () => Promise; + email: string; +}) { + const { showConfirm } = useToast(); const [pending, setPending] = useState(false); const handleClick = async () => { - if (!confirm(MESSAGES.unbanConfirm)) return; + const confirmed = await showConfirm({ + title: "Unban user", + message: `Unban ${email}? They will be able to join this sheet again via the join link.`, + confirmText: "Unban", + cancelText: "Cancel", + type: "info", + }); + if (!confirmed) return; + setPending(true); try { await onClick(); @@ -84,12 +119,17 @@ function UnbanButton({ onClick }: { onClick: () => Promise }) { return ( ); } diff --git a/src/components/guard/CleanupAlert.tsx b/src/components/guard/CleanupAlert.tsx deleted file mode 100644 index d1546bd1..00000000 --- a/src/components/guard/CleanupAlert.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { MESSAGES } from "./consts"; - -interface CleanupAlertProps { - onCleanup: () => void; - isPending?: boolean; -} - -export function CleanupAlert({ onCleanup, isPending }: CleanupAlertProps) { - return ( -
-

{MESSAGES.cleanupRecommended}

-

- Running cleanup will revoke access from unverified users. You will need - to re-join using the Guard link afterwards. -

-
- -
-
- ); -} diff --git a/src/components/guard/CopyLinkButton.tsx b/src/components/guard/CopyLinkButton.tsx index 8928a781..6fae3c04 100644 --- a/src/components/guard/CopyLinkButton.tsx +++ b/src/components/guard/CopyLinkButton.tsx @@ -1,17 +1,14 @@ import { useState } from "react"; import { MESSAGES } from "./consts"; - -interface CopyLinkButtonProps { - text: string; - variant?: "primary" | "secondary"; - size?: "sm" | "md" | "lg"; -} +import { cn } from "@/lib/ui/cn"; export function CopyLinkButton({ text, - variant = "primary", - size = "md", -}: CopyLinkButtonProps) { + className, +}: { + text: string; + className?: string; +}) { const [copied, setCopied] = useState(false); const handleCopy = async () => { @@ -24,28 +21,17 @@ export function CopyLinkButton({ } }; - const baseClasses = "rounded-field font-medium transition-all"; - const sizeClasses = { - sm: "px-2.5 py-1.5 text-xs", - md: "px-3.5 py-2.5 text-sm", - lg: "px-4 py-3 text-base", - }; - const variantClasses = { - primary: copied - ? "bg-green-500 text-white" - : "bg-primary text-white hover:bg-[#6600CC]", - secondary: copied - ? "bg-green-500 text-white" - : "border-2 border-base-content/20 hover:border-base-content/40", - }; - return ( ); } diff --git a/src/components/guard/CopySheetPage.tsx b/src/components/guard/CopySheetPage.tsx new file mode 100644 index 00000000..974e8247 --- /dev/null +++ b/src/components/guard/CopySheetPage.tsx @@ -0,0 +1,210 @@ +import { guardTypes } from "@/api/guard"; +import { formatApiErrorMessage } from "@/api/helpers/create-query-client"; +import { useEffect, useState } from "react"; +import { CopyInstructions } from "./Instructions"; +import { RolesSwitch } from "./JoinsList"; +import { SetupResult } from "./SetupResult"; +import { FileRole, useGuardMutations, useServiceAccountEmail } from "./hooks"; +import { extractSpreadsheetId, loadGmail, saveGmail } from "./utils"; + +export function CopySheetPage() { + const { serviceEmail, isPending: isEmailPending } = useServiceAccountEmail(); + const [spreadsheetId, setSpreadsheetId] = useState(""); + const [gmail, setGmail] = useState(""); + const [respondentRole, setRespondentRole] = useState( + FileRole.writer, + ); + const [error, setError] = useState(""); + const [setupResult, setSetupResult] = useState<{ + title: string; + fileId: string; + fileType: string; + guardingMethod: string; + roleDisplay: string; + joinLink: string; + } | null>(null); + + const mutations = useGuardMutations(); + + useEffect(() => { + const savedGmail = loadGmail(); + if (savedGmail) { + setGmail(savedGmail); + } + }, []); + + const validateSpreadsheetId = (input: string): string => { + if (!input.trim()) return ""; + + const extractedId = extractSpreadsheetId(input); + + if (!extractedId) { + return "Invalid Google Spreadsheet URL"; + } + + if (extractedId.length < 10) { + return "Spreadsheet ID seems too short"; + } + + return ""; + }; + + const handleBlur = () => { + if (spreadsheetId.trim()) { + setError(validateSpreadsheetId(spreadsheetId)); + } + }; + + const handleSubmit = () => { + setError(""); + + if (!gmail.trim()) { + setError("Gmail is required"); + return; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(gmail.trim())) { + setError("Please enter a valid Gmail address"); + return; + } + + if (!spreadsheetId.trim()) { + setError("Spreadsheet URL is required"); + return; + } + + const validationError = validateSpreadsheetId(spreadsheetId); + if (validationError) { + setError(validationError); + return; + } + + const extractedId = extractSpreadsheetId(spreadsheetId); + if (!extractedId) { + setError("Failed to extract Spreadsheet ID"); + return; + } + + const copyRole = + respondentRole === FileRole.writer + ? guardTypes.CopyFileRequestDefault_role.writer + : guardTypes.CopyFileRequestDefault_role.reader; + + saveGmail(gmail.trim()); + + mutations.copyFile.mutate( + { + body: { + file_id: extractedId, + default_role: copyRole, + owner_gmail: gmail.trim(), + }, + }, + { + onSuccess: (data) => { + setSetupResult({ + title: data.title, + fileId: data.file_id, + fileType: data.file_type, + guardingMethod: "copy", + roleDisplay: data.default_role, + joinLink: data.join_link, + }); + setSpreadsheetId(""); + mutations.invalidateFiles(); + }, + onError: (err) => { + setError(formatApiErrorMessage(err)); + }, + }, + ); + }; + + const isSubmitting = mutations.copyFile.isPending; + + return ( +
+ {isEmailPending ? ( +
+ ) : ( + + )} + +
{ + e.preventDefault(); + handleSubmit(); + }} + > +
+ Spreadsheet URL + { + setSpreadsheetId(e.target.value); + if (error) setError(""); + }} + onBlur={handleBlur} + placeholder="https://docs.google.com/spreadsheets/d/..." + className="input input-bordered w-full" + /> +
+ +
+ Owner Gmail + { + setGmail(e.target.value); + if (error) setError(""); + }} + placeholder="your.email@gmail.com" + className="input input-bordered w-full" + /> +
+ +
+ Guests Role + +
+ + {error && ( +
+ {error} +
+ )} + + +
+ + {setupResult && ( + setSetupResult(null)} + /> + )} +
+ ); +} diff --git a/src/components/guard/CreateSheetPage.tsx b/src/components/guard/CreateSheetPage.tsx new file mode 100644 index 00000000..de095efe --- /dev/null +++ b/src/components/guard/CreateSheetPage.tsx @@ -0,0 +1,163 @@ +import { guardTypes } from "@/api/guard"; +import { formatApiErrorMessage } from "@/api/helpers/create-query-client"; +import { useEffect, useState } from "react"; +import { RolesSwitch } from "./JoinsList"; +import { SetupResult } from "./SetupResult"; +import { FileRole, useGuardMutations } from "./hooks"; +import { loadGmail, saveGmail } from "./utils"; + +export function CreateSheetPage() { + const [title, setTitle] = useState(""); + const [gmail, setGmail] = useState(""); + const [respondentRole, setRespondentRole] = useState( + FileRole.writer, + ); + const [error, setError] = useState(""); + const [setupResult, setSetupResult] = useState<{ + title: string; + fileId: string; + fileType: string; + guardingMethod: string; + roleDisplay: string; + joinLink: string; + } | null>(null); + + const mutations = useGuardMutations(); + + useEffect(() => { + const savedGmail = loadGmail(); + if (savedGmail) { + setGmail(savedGmail); + } + }, []); + + const handleSubmit = () => { + setError(""); + + if (!gmail.trim()) { + setError("Gmail is required"); + return; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(gmail.trim())) { + setError("Please enter a valid Gmail address"); + return; + } + + if (!title.trim()) { + setError("Title is required"); + return; + } + + saveGmail(gmail.trim()); + + mutations.createFile.mutate( + { + body: { + file_type: guardTypes.CreateFileRequestFile_type.spreadsheet, + title: title.trim(), + default_role: respondentRole, + owner_gmail: gmail.trim(), + }, + }, + { + onSuccess: (data) => { + setSetupResult({ + title: data.title, + fileId: data.file_id, + fileType: "spreadsheet", + guardingMethod: "create", + roleDisplay: data.default_role, + joinLink: data.join_link, + }); + setTitle(""); + mutations.invalidateFiles(); + }, + onError: (err) => { + setError(formatApiErrorMessage(err)); + }, + }, + ); + }; + + const isSubmitting = mutations.createFile.isPending; + + return ( +
+
{ + e.preventDefault(); + handleSubmit(); + }} + > +
+ Title + { + setTitle(e.target.value); + if (error) setError(""); + }} + placeholder="e.g., CSE electives" + className="input input-bordered w-full" + /> +
+ +
+ Owner Gmail + { + setGmail(e.target.value); + if (error) setError(""); + }} + placeholder="your.email@gmail.com" + className="input input-bordered w-full" + /> +
+ +
+ Guests Role + +
+ + {error && ( +
+ {error} +
+ )} + + +
+ + {setupResult && ( + setSetupResult(null)} + /> + )} +
+ ); +} diff --git a/src/components/guard/EditTitleButton.tsx b/src/components/guard/EditTitleButton.tsx index 3ac8a6b9..90fee566 100644 --- a/src/components/guard/EditTitleButton.tsx +++ b/src/components/guard/EditTitleButton.tsx @@ -1,29 +1,29 @@ -import { useState } from "react"; - -interface EditTitleButtonProps { - currentTitle: string; - onSave: (newTitle: string) => Promise; -} +import { useEffect, useState } from "react"; export function EditTitleButton({ currentTitle, onSave, -}: EditTitleButtonProps) { - const [isEditing, setIsEditing] = useState(false); +}: { + currentTitle: string; + onSave: (newTitle: string) => Promise; +}) { const [title, setTitle] = useState(currentTitle); const [isPending, setIsPending] = useState(false); - const handleSave = async () => { - if (!title.trim() || title === currentTitle) { - setIsEditing(false); + useEffect(() => { + setTitle(currentTitle); + }, [currentTitle]); + + const handleBlur = async () => { + const nextTitle = title.trim(); + if (!nextTitle || nextTitle === currentTitle) { setTitle(currentTitle); return; } setIsPending(true); try { - await onSave(title.trim()); - setIsEditing(false); + await onSave(nextTitle); } catch (error) { console.error("Failed to update title:", error); setTitle(currentTitle); @@ -32,57 +32,29 @@ export function EditTitleButton({ } }; - const handleCancel = () => { - setIsEditing(false); - setTitle(currentTitle); - }; - - if (isEditing) { - return ( -
- setTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleSave(); - } else if (e.key === "Escape") { - handleCancel(); - } - }} - autoFocus - disabled={isPending} - className="border-base-content/20 bg-base-200/5 focus:border-primary flex-1 rounded-sm border-2 px-2 py-1 text-base outline-hidden transition-colors disabled:opacity-50" - /> - - -
- ); - } - return ( -
-

{currentTitle || "Untitled"}

- +
+ setTitle(e.target.value)} + onBlur={handleBlur} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } else if (e.key === "Escape") { + setTitle(currentTitle); + e.currentTarget.blur(); + } + }} + disabled={isPending} + size={Math.max(title.length, 1)} + placeholder="Untitled" + className="text-base-content placeholder:text-base-content/40 field-sizing-content max-w-full min-w-0 bg-transparent p-0 text-lg font-semibold outline-none disabled:opacity-50" + /> + {isPending && ( + + )}
); } diff --git a/src/components/guard/FileDetails.tsx b/src/components/guard/FileDetails.tsx index ff633a22..ac3d6ebf 100644 --- a/src/components/guard/FileDetails.tsx +++ b/src/components/guard/FileDetails.tsx @@ -1,11 +1,12 @@ +import { useToast } from "@/components/toast"; +import { cn } from "@/lib/ui/cn"; import { useState } from "react"; -import { buildJoinLink, buildSheetsUrl, formatDate } from "./utils"; -import { CopyLinkButton } from "./CopyLinkButton"; -import { JoinsList } from "./JoinsList"; import { BannedList } from "./BannedList"; +import { CopyLinkButton } from "./CopyLinkButton"; import { EditTitleButton } from "./EditTitleButton"; -import { MESSAGES } from "./consts"; +import { JoinsList, RolesSwitch } from "./JoinsList"; import { FileRole } from "./hooks"; +import { buildJoinLink, buildSheetsUrl, formatDate } from "./utils"; type FileDetailsData = { title: string; @@ -27,20 +28,7 @@ type FileDetailsData = { }>; }; -interface FileDetailsProps { - slug: string; - file: FileDetailsData | undefined; - isLoading: boolean; - search: string; - onSearchChange: (search: string) => void; - onBack: () => void; - onDelete: () => Promise; - onUpdateTitle: (newTitle: string) => Promise; - onBan: (userId: string) => Promise; - onUnban: (userId: string) => Promise; - onUpdateDefaultRole: (role: string) => Promise; - onUpdateUserRole: (userId: string, role: string) => Promise; -} +type PeopleTab = "joins" | "banned"; export function FileDetails({ slug, @@ -55,12 +43,41 @@ export function FileDetails({ onUnban, onUpdateDefaultRole, onUpdateUserRole, -}: FileDetailsProps) { +}: { + slug: string; + file: FileDetailsData | undefined; + isLoading: boolean; + search: string; + onSearchChange: (search: string) => void; + onBack: () => void; + onDelete: () => Promise; + onUpdateTitle: (newTitle: string) => Promise; + onBan: (userId: string) => Promise; + onUnban: (userId: string) => Promise; + onUpdateDefaultRole: (role: string) => Promise; + onUpdateUserRole: (userId: string, role: string) => Promise; +}) { + const { showConfirm } = useToast(); const [isDeleting, setIsDeleting] = useState(false); const [isUpdatingRole, setIsUpdatingRole] = useState(false); + const [peopleTab, setPeopleTab] = useState("joins"); + + const joinsCount = file?.sso_joins?.length ?? 0; + const bannedCount = file?.sso_banned?.length ?? 0; + const currentRole = + file?.default_role === FileRole.reader ? FileRole.reader : FileRole.writer; + const sheetTitle = file?.title || "Untitled"; const handleDelete = async () => { - if (!confirm(MESSAGES.deleteConfirm)) return; + const confirmed = await showConfirm({ + title: "Delete sheet", + message: `Delete "${sheetTitle}"? The join link will stop working, and access managed by Guard will be removed. This cannot be undone.`, + confirmText: "Delete", + cancelText: "Cancel", + type: "error", + }); + if (!confirmed) return; + setIsDeleting(true); try { await onDelete(); @@ -69,15 +86,24 @@ export function FileDetails({ } }; - const handleUpdateDefaultRole = async (newRole: FileRole) => { - if (!file) return; + const handleGuestsRoleSwitch = async (newRole: FileRole) => { + if (!file || newRole === currentRole) return; const affectedCount = file.sso_joins?.length || 0; const roleLabel = newRole === FileRole.writer ? "Writer" : "Reader"; + const message = + affectedCount > 0 + ? `New guests will get ${roleLabel} access. Permissions for ${affectedCount} already joined user${affectedCount === 1 ? "" : "s"} will be updated too.` + : `New guests will get ${roleLabel} access when they join via the link.`; - const message = `Update all roles to ${roleLabel}?\n\nThis will update the default role to ${roleLabel} and change permissions for ${affectedCount} user${affectedCount === 1 ? "" : "s"}. Are you sure?`; - - if (!confirm(message)) return; + const confirmed = await showConfirm({ + title: `Set guests role to ${roleLabel}`, + message, + confirmText: `Set to ${roleLabel}`, + cancelText: "Cancel", + type: "warning", + }); + if (!confirmed) return; setIsUpdatingRole(true); try { @@ -88,113 +114,155 @@ export function FileDetails({ }; return ( -
-
-
- -

- created at {file?.created_at ? formatDate(file.created_at) : "—"} -

-
-
- -
-
- -
- - +
+
- -
+
+
+ +
+ + {file?.created_at ? formatDate(file.created_at) : "—"} + + {!isLoading && ( + <> + + {joinsCount} {joinsCount === 1 ? "join" : "joins"} + + {bannedCount > 0 && ( + + {bannedCount} banned + + )} + + )} +
+
- {file && ( -
-

- Default Role:{" "} - - {file?.default_role} - -

-
+
+ + { + if (!file?.file_id) e.preventDefault(); + }} + > + + Open +
- )} - -
- onSearchChange(e.target.value)} - placeholder="Search by gmail or innomail" - className="border-base-content/20 bg-base-200/5 focus:border-base-content/40 focus:bg-base-200/10 rounded-field w-full border-2 px-4 py-3 outline-hidden transition-colors" - />
- {isLoading ? ( -
Loading...
- ) : ( - <> -
-

Joins

- +
+
+

Guests Role

+

+ Role granted when someone joins via the link +

+
+ {isUpdatingRole ? ( + + ) : file ? ( + + ) : ( +
+ )} +
+ + +
+
+
+ +
-
-

Banned

- + + onSearchChange(e.target.value)} + placeholder="Search email" + className="grow" /> + +
+ + {isLoading ? ( +
+
+
- - )} + ) : peopleTab === "joins" ? ( + + ) : ( + + )} +
); } diff --git a/src/components/guard/FilesList.tsx b/src/components/guard/FilesList.tsx index e3736024..68829818 100644 --- a/src/components/guard/FilesList.tsx +++ b/src/components/guard/FilesList.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { buildJoinLink, parseSlugFromJoinLink, formatDate } from "./utils"; +import { buildJoinLink, formatDate, parseSlugFromJoinLink } from "./utils"; import { CopyLinkButton } from "./CopyLinkButton"; type FileItem = { @@ -10,13 +10,15 @@ type FileItem = { default_role: string; }; -interface FilesListProps { +export function FilesList({ + files, + search, + onShowDetails, +}: { files: FileItem[]; search: string; onShowDetails: (slug: string) => void; -} - -export function FilesList({ files, search, onShowDetails }: FilesListProps) { +}) { const normalizedSearch = search.trim().toLowerCase(); const searchSlug = useMemo(() => { @@ -35,42 +37,56 @@ export function FilesList({ files, search, onShowDetails }: FilesListProps) { }, [files, normalizedSearch, searchSlug]); if (files.length === 0) { - return
No files found.
; + return

No files found.

; + } + + if (filtered.length === 0) { + return

No files match your search.

; } return (
{filtered.map((file) => ( - + ))}
); } -interface FileItemProps { +function FileItemCard({ + file, + onShowDetails, +}: { file: FileItem; onShowDetails: (slug: string) => void; -} - -function FileItem({ file, onShowDetails }: FileItemProps) { +}) { return ( -
+
onShowDetails(file.slug)} + className="bg-base-200 hover:bg-base-300 rounded-box flex cursor-pointer flex-col gap-3 px-4 py-3 transition-colors sm:flex-row sm:items-center sm:justify-between" + >
{file.title || "Untitled"}
-
- {file.default_role}, {file.sso_joins_count}{" "} - {file.sso_joins_count === 1 ? "join" : "joins"}, created at{" "} - {formatDate(file.created_at)} +
+ + + {file.sso_joins_count} + + + + {formatDate(file.created_at)} +
-
- - +
e.stopPropagation()} + > +
); diff --git a/src/components/guard/GuardPage.tsx b/src/components/guard/FilesPage.tsx similarity index 58% rename from src/components/guard/GuardPage.tsx rename to src/components/guard/FilesPage.tsx index 387923ee..9139d6f6 100644 --- a/src/components/guard/GuardPage.tsx +++ b/src/components/guard/FilesPage.tsx @@ -1,28 +1,22 @@ +import { guardTypes } from "@/api/guard"; import { formatApiErrorMessage } from "@/api/helpers/create-query-client"; import { useState } from "react"; -import { - useServiceAccountEmail, - useGuardFiles, - useGuardFile, - useGuardMutations, -} from "./hooks"; -import { SetupContainer } from "./SetupContainer"; -import { FilesSection } from "./FilesSection"; -import { FilesList } from "./FilesList"; import { FileDetails } from "./FileDetails"; +import { FilesList } from "./FilesList"; +import { FilesSection } from "./FilesSection"; +import { useGuardFile, useGuardFiles, useGuardMutations } from "./hooks"; -export function GuardPage() { +export function FilesPage() { const [selectedSlug, setSelectedSlug] = useState(null); const [filesSearch, setFilesSearch] = useState(""); const [detailsSearch, setDetailsSearch] = useState(""); - const { serviceEmail } = useServiceAccountEmail(); const { files, - isLoading: isLoadingFiles, + isPending: isLoadingFiles, error: filesError, } = useGuardFiles(); - const { file: selectedFile, isLoading: isLoadingFile } = + const { file: selectedFile, isPending: isLoadingFile } = useGuardFile(selectedSlug); const mutations = useGuardMutations(); @@ -85,7 +79,7 @@ export function GuardPage() { await mutations.updateDefaultRole.mutateAsync({ params: { path: { slug: selectedSlug } }, - body: { role: role as any }, + body: { role: role as guardTypes.UpdateDefaultRoleRequestRole }, }); mutations.invalidateFile(selectedSlug); @@ -97,54 +91,54 @@ export function GuardPage() { await mutations.updateUserRole.mutateAsync({ params: { path: { slug: selectedSlug, user_id: userId } }, - body: { role: role as any }, + body: { role: role as guardTypes.UpdateUserRoleRequestRole }, }); mutations.invalidateFile(selectedSlug); }; return ( -
-
- - -
- - {selectedSlug ? ( - - ) : ( - - {isLoadingFiles ? ( -
Loading...
- ) : filesError ? ( -
+
+ {selectedSlug ? ( + + ) : ( + + {isLoadingFiles ? ( +
+
+
+
+
+ ) : filesError ? ( +
+ Error loading files: {formatApiErrorMessage(filesError)} -
- ) : files.length > 0 ? ( - - ) : ( -
No files found.
- )} - - )} -
+ +
+ ) : files.length > 0 ? ( + + ) : ( +

No files found.

+ )} + + )}
); } diff --git a/src/components/guard/FilesSection.tsx b/src/components/guard/FilesSection.tsx index 8a463cac..a2f3e6f2 100644 --- a/src/components/guard/FilesSection.tsx +++ b/src/components/guard/FilesSection.tsx @@ -1,27 +1,26 @@ -interface FilesSectionProps { - search: string; - onSearchChange: (search: string) => void; - children: React.ReactNode; -} - export function FilesSection({ search, onSearchChange, children, -}: FilesSectionProps) { +}: { + search: string; + onSearchChange: (search: string) => void; + children: React.ReactNode; +}) { return ( -
-

Your Files

+
+

Your sheets

-
+
+ {children}
diff --git a/src/components/guard/GuardLandingPage.tsx b/src/components/guard/GuardLandingPage.tsx new file mode 100644 index 00000000..2cbf98b6 --- /dev/null +++ b/src/components/guard/GuardLandingPage.tsx @@ -0,0 +1,72 @@ +import { Link } from "@tanstack/react-router"; + +export function GuardLandingPage() { + return ( +
+
+

+ Guard protects Google Spreadsheets with SSO join links — only + signed-in university users get access, and you stay in control of who + can edit. +

+
    +
  • + + Spreadsheets owned by a service account so only Guard can grant + access +
  • +
  • + + Share a join link; respondents sign in via SSO and get the role you + choose +
  • +
  • + + Manage joins, roles, and bans from one place +
  • +
+
+ +
+ + +
+

Create sheet

+

+ Create a new protected spreadsheet from scratch +

+
+ + + + +
+

Copy sheet

+

+ Make a protected copy of an existing spreadsheet +

+
+ + + + +
+

Your sheets

+

+ View join links, roles, and access for your sheets +

+
+ +
+
+ ); +} diff --git a/src/components/guard/GuardTabs.tsx b/src/components/guard/GuardTabs.tsx new file mode 100644 index 00000000..5035653f --- /dev/null +++ b/src/components/guard/GuardTabs.tsx @@ -0,0 +1,23 @@ +import { Link, ValidateLinkOptions } from "@tanstack/react-router"; + +export function GuardTabs() { + return ( +
+ Info + Create Sheet + Copy Sheet + Your Sheets +
+ ); +} + +function TabLink(props: ValidateLinkOptions) { + return ( + + ); +} diff --git a/src/components/guard/Instructions.tsx b/src/components/guard/Instructions.tsx index 574ac36e..0c776c6c 100644 --- a/src/components/guard/Instructions.tsx +++ b/src/components/guard/Instructions.tsx @@ -1,10 +1,7 @@ import { useState } from "react"; +import { cn } from "@/lib/ui/cn"; -interface ServiceAccountEmailProps { - email: string; -} - -export function ServiceAccountEmail({ email }: ServiceAccountEmailProps) { +export function ServiceAccountEmail({ email }: { email: string }) { const [copied, setCopied] = useState(false); const handleCopy = async () => { @@ -20,77 +17,27 @@ export function ServiceAccountEmail({ email }: ServiceAccountEmailProps) { }; return ( - - {copied ? "✓ Copied!" : email} - + {copied ? "Copied!" : email || "…"} + ); } -interface CreateInstructionsProps { - serviceEmail: string; -} - -export function CreateInstructions({ serviceEmail }: CreateInstructionsProps) { - return ( -
-
    -
  1. - We will create a new Google Spreadsheet using the title you provide. -
  2. -
  3. - The service account will - own the file. -
  4. -
  5. - You will get a join link to share with respondents, they will get - access after signing in via SSO. -
  6. -
-
-

- 💡 Spreadsheet must be owned by the service account so only it can add - editors. Otherwise, anyone can add editors and you lose control over - who has access to the spreadsheet. -

-
-
- ); -} - -interface CopyInstructionsProps { - serviceEmail: string; -} - -export function CopyInstructions({ serviceEmail }: CopyInstructionsProps) { +export function CopyInstructions({ serviceEmail }: { serviceEmail: string }) { return ( -
-
    -
  1. - Open your spreadsheet and give Editor access to{" "} - -
  2. -
  3. Paste the spreadsheet URL below.
  4. -
  5. We will create a copy of your file owned by the service account
  6. -
  7. - You will get a join link to share with respondents, they will get - access after signing in via SSO. -
  8. -
  9. Your original file remains unchanged and under your control.
  10. -
-
-

- 💡 The protected copy is owned by the service account so only it can - add editors. This ensures you have full control over who has access. -

-
+
+ + Give Editor access to the service account{" "} + , then paste the spreadsheet + URL below. +
); } diff --git a/src/components/guard/JoinsList.tsx b/src/components/guard/JoinsList.tsx index 8b8b5f1e..cafbaf2d 100644 --- a/src/components/guard/JoinsList.tsx +++ b/src/components/guard/JoinsList.tsx @@ -1,7 +1,8 @@ -import { useState, useMemo } from "react"; -import { filterByFields } from "./utils"; -import { MESSAGES } from "./consts"; +import { useToast } from "@/components/toast"; +import { cn } from "@/lib/ui/cn"; +import { useMemo, useState } from "react"; import { FileRole } from "./hooks"; +import { filterByFields } from "./utils"; type JoinItem = { user_id: string; @@ -11,32 +12,44 @@ type JoinItem = { role: string; }; -interface JoinsListProps { - joins: JoinItem[]; - search: string; - onBan: (userId: string) => Promise; - onUpdateRole: (userId: string, role: string) => Promise; -} - export function JoinsList({ joins, search, onBan, onUpdateRole, -}: JoinsListProps) { +}: { + joins: JoinItem[]; + search: string; + onBan: (userId: string) => Promise; + onUpdateRole: (userId: string, role: string) => Promise; +}) { const filtered = useMemo( () => filterByFields(joins, search, ["gmail", "innomail"]), [joins, search], ); if (!joins || joins.length === 0) { - return
No joins yet.
; + return ( +
+ +

No one has joined yet

+

Share the join link to grant access

+
+ ); + } + + if (filtered.length === 0) { + return ( +

+ No joins match your search +

+ ); } return ( -
+
{filtered.map((join) => ( - Promise; onUpdateRole: (userId: string, role: string) => Promise; -} - -function JoinItem({ join, onBan, onUpdateRole }: JoinItemProps) { +}) { return ( -
+
- {/*

- joined at {formatDate(join.joined_at)} -

*/}
-
+
onUpdateRole(join.user_id, role)} /> - onBan(join.user_id)} /> + onBan(join.user_id)} + />
); } function Email({ email }: { email: string }) { + const [local, domain] = email.split("@"); return ( - - {email.split("@")[0]} - @{email.split("@")[1]} + + {local} + @{domain} ); } @@ -92,57 +108,65 @@ export function RolesSwitch({ }) { const [pending, setPending] = useState(false); const isWriter = currentRole === FileRole.writer; - const handleSwitch = async () => { - if (pending) return; - const nextRole = isWriter ? FileRole.reader : FileRole.writer; + + const handleSwitch = async (role: FileRole) => { + if (pending || role === currentRole) return; setPending(true); try { - await onSwitch(nextRole); + await onSwitch(role); } finally { setPending(false); } }; return ( - + +
); } -function BanButton({ onClick }: { onClick: () => Promise }) { +function BanButton({ + onClick, + email, +}: { + onClick: () => Promise; + email: string; +}) { + const { showConfirm } = useToast(); const [pending, setPending] = useState(false); const handleClick = async () => { - if (!confirm(MESSAGES.banConfirm)) return; + const confirmed = await showConfirm({ + title: "Ban user", + message: `Ban ${email}? They will lose access to this sheet and will not be able to join again until unbanned.`, + confirmText: "Ban", + cancelText: "Cancel", + type: "error", + }); + if (!confirmed) return; + setPending(true); try { await onClick(); @@ -153,12 +177,16 @@ function BanButton({ onClick }: { onClick: () => Promise }) { return ( ); } diff --git a/src/components/guard/ModeToggle.tsx b/src/components/guard/ModeToggle.tsx deleted file mode 100644 index 95f8172e..00000000 --- a/src/components/guard/ModeToggle.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { MODE_LABELS } from "./consts"; - -interface ModeToggleProps { - mode: "create" | "copy"; - onChange: (mode: "create" | "copy") => void; -} - -export function ModeToggle({ mode, onChange }: ModeToggleProps) { - return ( -
- - -
- ); -} diff --git a/src/components/guard/SetupContainer.tsx b/src/components/guard/SetupContainer.tsx deleted file mode 100644 index 89aba0d9..00000000 --- a/src/components/guard/SetupContainer.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { useState, useEffect } from "react"; -import { guardTypes } from "@/api/guard"; -import { formatApiErrorMessage } from "@/api/helpers/create-query-client"; -import { useGuardMutations, FileRole } from "./hooks"; -import { extractSpreadsheetId, loadGmail, saveGmail } from "./utils"; -import { ModeToggle } from "./ModeToggle"; -import { CreateInstructions, CopyInstructions } from "./Instructions"; -import { SetupResult } from "./SetupResult"; -import { DEFAULT_MODE } from "./consts"; -import { RolesSwitch } from "./JoinsList"; - -interface SetupContainerProps { - serviceEmail: string; -} - -export function SetupContainer({ serviceEmail }: SetupContainerProps) { - const [mode, setMode] = useState<"create" | "copy">(DEFAULT_MODE); - const [spreadsheetId, setSpreadsheetId] = useState(""); - const [title, setTitle] = useState(""); - const [gmail, setGmail] = useState(""); - const [respondentRole, setRespondentRole] = useState( - FileRole.writer, - ); - const [error, setError] = useState(""); - const [setupResult, setSetupResult] = useState<{ - title: string; - fileId: string; - fileType: string; - guardingMethod: string; - roleDisplay: string; - joinLink: string; - } | null>(null); - - const mutations = useGuardMutations(); - - // Load gmail from localStorage on mount - useEffect(() => { - const savedGmail = loadGmail(); - if (savedGmail) { - setGmail(savedGmail); - } - }, []); - - const validateSpreadsheetId = (input: string): string => { - if (!input.trim()) return ""; - - const extractedId = extractSpreadsheetId(input); - - if (!extractedId) { - return "Invalid Google Spreadsheet URL"; - } - - if (extractedId.length < 10) { - return "Spreadsheet ID seems too short"; - } - - return ""; - }; - - const handleSubmit = async () => { - setError(""); - - if (!gmail.trim()) { - setError("Gmail is required"); - return; - } - - // Basic email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(gmail.trim())) { - setError("Please enter a valid Gmail address"); - return; - } - - if (mode === "create") { - if (!title.trim()) { - setError("Title is required"); - return; - } - - // Save gmail to localStorage - saveGmail(gmail.trim()); - - mutations.createFile.mutate( - { - body: { - file_type: guardTypes.CreateFileRequestFile_type.spreadsheet, - title: title.trim(), - default_role: respondentRole, - owner_gmail: gmail.trim(), - }, - }, - { - onSuccess: (data) => { - setSetupResult({ - title: data.title, - fileId: data.file_id, - fileType: "spreadsheet", - guardingMethod: mode, - roleDisplay: data.default_role, - joinLink: data.join_link, - }); - setTitle(""); - mutations.invalidateFiles(); - }, - onError: (error) => { - setError(formatApiErrorMessage(error)); - }, - }, - ); - return; - } - - // Copy mode - if (!spreadsheetId.trim()) { - setError("Spreadsheet URL is required"); - return; - } - - const validationError = validateSpreadsheetId(spreadsheetId); - if (validationError) { - setError(validationError); - return; - } - - const extractedId = extractSpreadsheetId(spreadsheetId); - if (!extractedId) { - setError("Failed to extract Spreadsheet ID"); - return; - } - - const copyRole = - respondentRole === FileRole.writer - ? guardTypes.CopyFileRequestDefault_role.writer - : guardTypes.CopyFileRequestDefault_role.reader; - - // Save gmail to localStorage - saveGmail(gmail.trim()); - - mutations.copyFile.mutate( - { - body: { - file_id: extractedId, - default_role: copyRole, - owner_gmail: gmail.trim(), - }, - }, - { - onSuccess: (data) => { - setSetupResult({ - title: data.title, - fileId: data.file_id, - fileType: data.file_type, - guardingMethod: mode, - roleDisplay: data.default_role, - joinLink: data.join_link, - }); - setSpreadsheetId(""); - mutations.invalidateFiles(); - }, - onError: (error) => { - setError(formatApiErrorMessage(error)); - }, - }, - ); - }; - - const handleBlur = () => { - if (mode === "copy" && spreadsheetId.trim()) { - const validationError = validateSpreadsheetId(spreadsheetId); - setError(validationError); - } - }; - - const isSubmitting = - mode === "create" - ? mutations.createFile.isPending - : mutations.copyFile.isPending; - - return ( -
- - - {mode === "create" ? ( - - ) : ( - - )} - -
{ - e.preventDefault(); - handleSubmit(); - }} - > - {mode === "create" && ( -
- - { - setTitle(e.target.value); - if (error) setError(""); - }} - placeholder="e.g., CSE electives" - className="border-base-content/20 bg-base-200/5 focus:border-base-200 focus:bg-base-200/10 rounded-field w-full border-2 px-4 py-2 outline-hidden transition-colors" - /> -
- )} - - {mode === "copy" && ( -
- - { - setSpreadsheetId(e.target.value); - if (error) setError(""); - }} - onBlur={handleBlur} - placeholder="https://docs.google.com/spreadsheets/d/1gqfGNkl_nLI5vXc5br8-J__33kj8Kr3fI2xqrMBK2JA" - className="border-base-content/20 bg-base-200/5 focus:border-base-200 focus:bg-base-200/10 rounded-field w-full border-2 px-4 py-2 outline-hidden transition-colors" - /> -
- )} - -
- - { - setGmail(e.target.value); - if (error) setError(""); - }} - placeholder="your.email@gmail.com" - className="border-base-content/20 bg-base-200/5 focus:border-base-200 focus:bg-base-200/10 rounded-field w-full border-2 px-4 py-2 outline-hidden transition-colors" - /> -
- -
- - - {/*
- - -
*/} -
- - {error && ( -
- {error} -
- )} - - -
- - {setupResult && ( - setSetupResult(null)} - /> - )} -
- ); -} diff --git a/src/components/guard/SetupResult.tsx b/src/components/guard/SetupResult.tsx index c79c41a4..87104d68 100644 --- a/src/components/guard/SetupResult.tsx +++ b/src/components/guard/SetupResult.tsx @@ -1,10 +1,12 @@ -// import { useState } from "react"; -import { buildSheetsUrl, buildDocsUrl } from "./utils"; +import { buildDocsUrl, buildSheetsUrl } from "./utils"; import { CopyLinkButton } from "./CopyLinkButton"; import { useServiceAccountEmail } from "./hooks"; import { ServiceAccountEmail } from "./Instructions"; -interface SetupResultProps { +export function SetupResult({ + result, + onDismiss, +}: { result: { title: string; fileId: string; @@ -14,20 +16,7 @@ interface SetupResultProps { joinLink: string; }; onDismiss?: () => void; -} - -export function SetupResult({ result, onDismiss }: SetupResultProps) { - // const [copied, setCopied] = useState(false); - - // const handleCopy = async () => { - // try { - // await navigator.clipboard.writeText(result.joinLink); - // setCopied(true); - // setTimeout(() => setCopied(false), 2000); - // } catch (_) { - // console.log("Failed to copy to clipboard"); - // } - // }; +}) { const { serviceEmail } = useServiceAccountEmail(); const fileUrl = @@ -36,41 +25,34 @@ export function SetupResult({ result, onDismiss }: SetupResultProps) { : buildDocsUrl(result.fileId); return ( -
- {/*
*/} -

- ✅ Setup Complete! -

- +
-

+

Setup complete

+

New file was created under{" "} - ownership -
- You was added to the file with editor role + ownership. You were added + with editor role.

-

- You can edit spreadsheet or share join link with the students! +

+ Edit the spreadsheet or share the join link with students.

-
- - +
+ + + Open spreadsheet + {onDismiss ? ( diff --git a/src/components/guard/consts.ts b/src/components/guard/consts.ts index feb34201..ada4c1e0 100644 --- a/src/components/guard/consts.ts +++ b/src/components/guard/consts.ts @@ -1,26 +1,14 @@ export const GMAIL_STORAGE_KEY = "guard_gmail"; -export const DEFAULT_MODE = "create" as const; - export const ROLE_LABELS = { writer: "Writer", reader: "Reader", } as const; -export const MODE_LABELS = { - create: "Create Protected Spreadsheet", - copy: "Copy Existing Spreadsheet", -} as const; - export const MESSAGES = { setupSuccess: "Setup Complete!", copySuccess: "Copied!", - deleteConfirm: "Are you sure you want to delete this file?", - banConfirm: "Are you sure you want to ban this user?", - unbanConfirm: "Are you sure you want to unban this user?", cleanupRecommended: "Cleanup recommended: unverified users have access to this file.", noAccess: "No access to the document", } as const; - -export const MAX_LIST_HEIGHT = "max-h-[400px]"; diff --git a/src/components/guard/hooks.ts b/src/components/guard/hooks.ts index 7fb78f30..2d43040f 100644 --- a/src/components/guard/hooks.ts +++ b/src/components/guard/hooks.ts @@ -25,7 +25,7 @@ export function useServiceAccountEmail() { const query = $guard.useQuery("get", "/google/service-account-email"); return { serviceEmail: query.data?.email || "", - isLoading: query.isLoading, + isPending: query.isPending, }; } @@ -37,7 +37,7 @@ export function useGuardFiles() { return { files: query.data || [], - isLoading: query.isLoading, + isPending: query.isPending, error: query.error, }; } @@ -59,7 +59,7 @@ export function useGuardFile(slug: string | null) { return { file: query.data, - isLoading: query.isLoading, + isPending: query.isPending, }; } diff --git a/src/components/guard/index.ts b/src/components/guard/index.ts index 10bedccc..81c96758 100644 --- a/src/components/guard/index.ts +++ b/src/components/guard/index.ts @@ -1,19 +1,16 @@ -export { GuardPage } from "./GuardPage"; -export { SetupContainer } from "./SetupContainer"; +export { GuardLandingPage } from "./GuardLandingPage"; +export { GuardTabs } from "./GuardTabs"; +export { CreateSheetPage } from "./CreateSheetPage"; +export { CopySheetPage } from "./CopySheetPage"; +export { FilesPage } from "./FilesPage"; export { SetupResult } from "./SetupResult"; export { FilesSection } from "./FilesSection"; export { FilesList } from "./FilesList"; export { FileDetails } from "./FileDetails"; export { JoinsList } from "./JoinsList"; export { BannedList } from "./BannedList"; -export { ModeToggle } from "./ModeToggle"; -export { CleanupAlert } from "./CleanupAlert"; export { CopyLinkButton } from "./CopyLinkButton"; -export { - CreateInstructions, - CopyInstructions, - ServiceAccountEmail, -} from "./Instructions"; +export { CopyInstructions, ServiceAccountEmail } from "./Instructions"; export { EditTitleButton } from "./EditTitleButton"; export * from "./hooks"; export * from "./utils";