diff --git a/apps/web/features/dashboard/sites/site-actions-menu-items.tsx b/apps/web/features/dashboard/sites/site-actions-menu-items.tsx index 3b1bbccc..71254525 100644 --- a/apps/web/features/dashboard/sites/site-actions-menu-items.tsx +++ b/apps/web/features/dashboard/sites/site-actions-menu-items.tsx @@ -20,6 +20,7 @@ import { LinkSquare01Icon, PencilEdit01Icon, SentIcon, + UserAdd01Icon, ViewIcon, ViewOffIcon, } from "@hugeicons/core-free-icons"; @@ -36,6 +37,7 @@ type SiteActionsMenuItemsProps = { kind: MenuKind; onDelete?: () => void; onHistory: () => void; + onInviteGuests?: () => void; onPreview: () => void; onPublish: () => void; onSettings: () => void; @@ -54,6 +56,7 @@ export function SiteActionsMenuItems({ kind, onDelete, onHistory, + onInviteGuests, onPreview, onPublish, onSettings, @@ -128,6 +131,14 @@ export function SiteActionsMenuItems({ {sitePublished ? ( <> {action("share", SentIcon, tHeader("share"), onShare)} + {onInviteGuests + ? action( + "invite-guests", + UserAdd01Icon, + tHeader("inviteGuests"), + onInviteGuests, + ) + : null} {action( "history", FileClockIcon, diff --git a/apps/web/features/editor/editor-dialogs.tsx b/apps/web/features/editor/editor-dialogs.tsx index 7a6136af..69ca8ebf 100644 --- a/apps/web/features/editor/editor-dialogs.tsx +++ b/apps/web/features/editor/editor-dialogs.tsx @@ -13,11 +13,19 @@ const HistoryDialog = dynamic(() => const ShareDialog = dynamic(() => import("./share-dialog").then((module) => module.ShareDialog), ); +const GuestAccessDialog = dynamic(() => + import("./guest-access-dialog").then((module) => module.GuestAccessDialog), +); const SiteSettingsDialog = dynamic(() => import("./site-settings-dialog").then((module) => module.SiteSettingsDialog), ); -export type EditorDialogName = "history" | "publish" | "settings" | "share"; +export type EditorDialogName = + | "guests" + | "history" + | "publish" + | "settings" + | "share"; export interface EditorDialogState { name: EditorDialogName; @@ -65,13 +73,20 @@ export function EditorDialogs({ return ( ); + case "guests": + return pageId ? ( + + ) : null; case "settings": return ( ; + normalizedEmail: string; + permission: Permission; + status: string; + }>; + grants: Array<{ + _id: Id<"pageGuestGrants">; + email: string; + name?: string; + permission: Permission; + }>; +}; + +export function GuestAccessDialog({ + onOpenChange, + pageId, + returnFocusTo, +}: { + onOpenChange: (open: boolean) => void; + pageId: Id<"pages">; + returnFocusTo?: HTMLElement | null; +}) { + const t = useTranslations("editor.guests"); + const locale = useLocale(); + const titleRef = useRef(null); + const [email, setEmail] = useState(""); + const [permission, setPermission] = useState("viewer"); + const [submitting, setSubmitting] = useState(false); + const list = useQuery(workspaceApi.pageGuests.listForPage, { pageId }) as + | GuestAccessList + | undefined; + const invite = useMutation(workspaceApi.pageGuests.invite); + const updateGrant = useMutation(workspaceApi.pageGuests.updateGrant); + const revokeGrant = useMutation(workspaceApi.pageGuests.revokeGrant); + const revokeInvitation = useMutation( + workspaceApi.pageGuests.revokeInvitation, + ); + const pendingInvitations = + list?.invitations.filter((invitation) => invitation.status === "pending") ?? + []; + const hasGuests = Boolean(list?.grants.length || pendingInvitations.length); + + const handleInvite = async (event: React.FormEvent) => { + event.preventDefault(); + setSubmitting(true); + try { + const result = (await invite({ pageId, email, permission })) as { + token: string; + }; + const link = `${window.location.origin}/${locale}/guest/invitations/${result.token}`; + await navigator.clipboard.writeText(link); + setEmail(""); + toast.success(t("linkCopied")); + } catch (error) { + toast.error(error instanceof Error ? error.message : t("inviteFailed")); + } finally { + setSubmitting(false); + } + }; + + const handleGrantPermission = async ( + grantId: Id<"pageGuestGrants">, + nextPermission: string, + ) => { + try { + await updateGrant({ + grantId, + permission: nextPermission as Permission, + }); + } catch { + toast.error(t("updateFailed")); + } + }; + + return ( + + { + event.preventDefault(); + titleRef.current?.focus(); + }} + returnFocusTo={returnFocusTo} + > + + + {t("title")} + + + {t("description")} + + + +
+
+ + setEmail(event.target.value)} + placeholder="name@example.com" + required + type="email" + value={email} + /> +
+
+ + +
+ +
+ +
+

{t("accessTitle")}

+ {list === undefined ? ( +
+ + {t("loading")} +
+ ) : hasGuests ? ( +
+ {list.grants.map((grant) => { + const name = grant.name || grant.email; + return ( +
+ + + + + {name} + + + +
+ ); + })} + {pendingInvitations.map((invitation) => ( +
+ + + + + + {invitation.normalizedEmail} + + + {t("pending")} + + + +
+ ))} +
+ ) : ( +

+ {t("empty")} +

+ )} +
+
+
+ ); +} diff --git a/apps/web/features/editor/guest-access-section.tsx b/apps/web/features/editor/guest-access-section.tsx deleted file mode 100644 index d5169015..00000000 --- a/apps/web/features/editor/guest-access-section.tsx +++ /dev/null @@ -1,166 +0,0 @@ -"use client"; - -import { workspaceApi } from "@/lib/convex/workspace-api"; -import type { Id } from "@baseblocks/backend"; -import { Button } from "@baseblocks/ui/button"; -import { Input } from "@baseblocks/ui/input"; -import { Label } from "@baseblocks/ui/label"; -import { Spinner } from "@baseblocks/ui/spinner"; -import { useMutation, useQuery } from "convex/react"; -import { useLocale, useTranslations } from "next-intl"; -import { useState } from "react"; -import { toast } from "sonner"; - -type GuestAccessList = { - invitations: Array<{ - _id: Id<"pageGuestInvitations">; - normalizedEmail: string; - permission: "viewer" | "editor"; - status: string; - }>; - grants: Array<{ - _id: Id<"pageGuestGrants">; - email: string; - name?: string; - permission: "viewer" | "editor"; - }>; -}; - -export function GuestAccessSection({ pageId }: { pageId: Id<"pages"> }) { - const t = useTranslations("editor.share.guests"); - const locale = useLocale(); - const [email, setEmail] = useState(""); - const [permission, setPermission] = useState<"viewer" | "editor">("viewer"); - const [submitting, setSubmitting] = useState(false); - const list = useQuery(workspaceApi.pageGuests.listForPage, { pageId }) as - | GuestAccessList - | undefined; - const invite = useMutation(workspaceApi.pageGuests.invite); - const updateGrant = useMutation(workspaceApi.pageGuests.updateGrant); - const revokeGrant = useMutation(workspaceApi.pageGuests.revokeGrant); - const revokeInvitation = useMutation( - workspaceApi.pageGuests.revokeInvitation, - ); - - return ( -
-
-

{t("title")}

-

{t("description")}

-
-
{ - event.preventDefault(); - setSubmitting(true); - try { - const result = (await invite({ pageId, email, permission })) as { - token: string; - }; - const link = `${window.location.origin}/${locale}/guest/invitations/${result.token}`; - await navigator.clipboard.writeText(link); - setEmail(""); - toast.success(t("linkCopied")); - } catch (error) { - toast.error( - error instanceof Error ? error.message : t("inviteFailed"), - ); - } finally { - setSubmitting(false); - } - }} - > - - setEmail(event.target.value)} - placeholder="name@example.com" - required - type="email" - value={email} - /> - - - -
- {list ? ( -
- {list.grants.map((grant) => ( -
- - {grant.name || grant.email} - - - -
- ))} - {list.invitations - .filter((invitation) => invitation.status === "pending") - .map((invitation) => ( -
- - {invitation.normalizedEmail} · {t("pending")} - - -
- ))} -
- ) : null} -
- ); -} diff --git a/apps/web/features/editor/share-dialog.tsx b/apps/web/features/editor/share-dialog.tsx index 20048f73..5e7dbfc1 100644 --- a/apps/web/features/editor/share-dialog.tsx +++ b/apps/web/features/editor/share-dialog.tsx @@ -1,194 +1,194 @@ "use client"; -import { HugeiconsIcon } from "@hugeicons/react"; import { getSiteUrl } from "@/features/published-sites/urls"; -import { - Copy01Icon, - GlobeIcon, - Tick01Icon, - ViewIcon, - ViewOffIcon, -} from "@hugeicons/core-free-icons"; -import { api } from "@baseblocks/backend"; -import type { Id } from "@baseblocks/backend"; +import { api, type Id } from "@baseblocks/backend"; import { Button } from "@baseblocks/ui/button"; import { Dialog, DialogContent, - DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@baseblocks/ui/dialog"; -import { Label } from "@baseblocks/ui/label"; +import { cn } from "@baseblocks/ui/lib/utils"; import { RadioGroup, RadioGroupItem } from "@baseblocks/ui/radio-group"; import { Spinner } from "@baseblocks/ui/spinner"; +import { + Copy01Icon, + GlobeIcon, + Tick01Icon, + ViewIcon, + ViewOffIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; import { useMutation, useQuery } from "convex/react"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { toast } from "sonner"; -import { GuestAccessSection } from "@/features/editor/guest-access-section"; type Visibility = "private" | "public"; interface ShareDialogProps { onOpenChange: (open: boolean) => void; - pageId?: Id<"pages">; returnFocusTo?: HTMLElement | null; siteId: Id<"sites">; - teamSlug: string; siteSlug: string; + teamSlug: string; } -function VisibilityOptionCard({ +function VisibilityOption({ + active, description, icon, - id, label, value, }: { + active: boolean; description: string; icon: React.ReactNode; - id: string; label: string; value: Visibility; }) { + const id = `site-visibility-${value}`; + return ( -
- -
- -

{description}

-
-
+ ); } export function ShareDialog({ onOpenChange, - pageId, returnFocusTo, siteId, - teamSlug, siteSlug, + teamSlug, }: ShareDialogProps) { const t = useTranslations("editor.share"); + const titleRef = useRef(null); const [copied, setCopied] = useState(false); - const updateVisibilityMut = useMutation(api.sharing.updateVisibility); + const [updating, setUpdating] = useState(false); + const updateVisibility = useMutation(api.sharing.updateVisibility); const settings = useQuery(api.sharing.getSettings, { siteId }); const siteUrl = getSiteUrl(teamSlug, siteSlug); const visibility = settings?.visibility; - const handleVisibilityChange = async (value: Visibility) => { + const handleVisibilityChange = async (value: string) => { + if (value === visibility || updating) return; + setUpdating(true); try { - await updateVisibilityMut({ + await updateVisibility({ siteId, - visibility: value, + visibility: value as Visibility, }); toast.success(t("toastVisibilityUpdated")); } catch { toast.error(t("toastVisibilityFailed")); + } finally { + setUpdating(false); } }; - const copyLink = () => { - navigator.clipboard.writeText(siteUrl); + const copyLink = async () => { + await navigator.clipboard.writeText(siteUrl); setCopied(true); toast.success(t("toastLinkCopied")); - setTimeout(() => setCopied(false), 2000); + window.setTimeout(() => setCopied(false), 2000); }; return ( { + event.preventDefault(); + titleRef.current?.focus(); + }} returnFocusTo={returnFocusTo} > - - + + {t("title")} - - {t("description")} - -
-
- {visibility ? ( - - void handleVisibilityChange(value as Visibility) - } - > - - } - id="public" - label={t("visibilityPublicLabel")} - value="public" - /> - - } - id="private" - label={t("visibilityPrivateLabel")} - value="private" - /> - - ) : ( -
- Loading sharing settings - -
- )} - {pageId ? : null} -
- - - - + } + label={t("visibilityPublicLabel")} + value="public" + /> + } + label={t("visibilityPrivateLabel")} + value="private" + /> + + ) : ( +
+ + {t("loading")} +
+ )}
+ + + + +
); diff --git a/apps/web/features/editor/site-header-content.tsx b/apps/web/features/editor/site-header-content.tsx index 1baddfbe..2fa80913 100644 --- a/apps/web/features/editor/site-header-content.tsx +++ b/apps/web/features/editor/site-header-content.tsx @@ -27,6 +27,7 @@ import { SiteHeaderMoreActions } from "./site-header-more-actions"; interface SiteHeaderContentProps { isPreviewing?: boolean; + pageId?: Id<"pages">; teamSlug: string; siteSlug: string; siteId: Id<"sites">; @@ -45,6 +46,7 @@ interface SiteHeaderContentProps { export function SiteHeaderContent({ isPreviewing = false, + pageId, teamSlug, siteSlug, siteId, @@ -75,6 +77,7 @@ export function SiteHeaderContent({ onOpenDialog={onOpenDialog} onTogglePreview={onTogglePreview} onUnpublish={onUnpublish} + pageId={pageId} saveStatus={saveStatus} sitePublished={sitePublished} hasUnpublishedChanges={hasUnpublishedChanges} @@ -111,6 +114,7 @@ function SiteHeaderActions({ canEdit, canManageSites, isPreviewing, + pageId, onOpenDialog, onTogglePreview, onUnpublish, @@ -128,6 +132,7 @@ function SiteHeaderActions({ canEdit: boolean; canManageSites: boolean; isPreviewing: boolean; + pageId?: Id<"pages">; onOpenDialog: ( dialog: EditorDialogName, returnFocusTo: HTMLElement | null, @@ -165,6 +170,7 @@ function SiteHeaderActions({ onOpenDialog={onOpenDialog} onTogglePreview={onTogglePreview} onUnpublish={onUnpublish} + pageId={pageId} sitePublished={sitePublished} siteId={siteId} siteSlug={siteSlug} diff --git a/apps/web/features/editor/site-header-more-actions.tsx b/apps/web/features/editor/site-header-more-actions.tsx index 7b1ee295..3d9a1cba 100644 --- a/apps/web/features/editor/site-header-more-actions.tsx +++ b/apps/web/features/editor/site-header-more-actions.tsx @@ -27,6 +27,7 @@ export function SiteHeaderMoreActions({ onOpenDialog, onTogglePreview, onUnpublish, + pageId, sitePublished, siteId, siteSlug, @@ -42,6 +43,7 @@ export function SiteHeaderMoreActions({ ) => void; onTogglePreview?: () => void; onUnpublish?: () => void; + pageId?: string; sitePublished: boolean; siteId: string; siteSlug: string; @@ -76,6 +78,11 @@ export function SiteHeaderMoreActions({ kind="dropdown" onDelete={() => setDeleteOpen(true)} onHistory={() => onOpenDialog("history", triggerRef.current)} + onInviteGuests={ + pageId + ? () => onOpenDialog("guests", triggerRef.current) + : undefined + } onPreview={() => onTogglePreview?.()} onPublish={() => onOpenDialog("publish", triggerRef.current)} onSettings={() => onOpenDialog("settings", triggerRef.current)} diff --git a/packages/i18n/src/messages/en.json b/packages/i18n/src/messages/en.json index 4395e4e6..35d5cd63 100644 --- a/packages/i18n/src/messages/en.json +++ b/packages/i18n/src/messages/en.json @@ -168,6 +168,7 @@ "viewPublishedTooltipWhenPublished": "Open published site", "viewPublishedTooltipWhenDraft": "Publish your site first", "share": "Share", + "inviteGuests": "Invite guests", "deploymentHistory": "Deployment history", "deployShort": "Deploy", "publishedStatus": "Published", @@ -227,33 +228,38 @@ "deploying": "Deploying…" }, "share": { - "title": "Share settings", - "description": "Control who can view your published site.", + "title": "Share site", + "visibilityLabel": "Site visibility", "visibilityPublicLabel": "Public", "visibilityPublicDescription": "Anyone can view this site", "visibilityPrivateLabel": "Private", "visibilityPrivateDescription": "Only team members can view", + "loading": "Loading share settings", "toastVisibilityUpdated": "Visibility updated", "toastVisibilityFailed": "Failed to update visibility", "toastLinkCopied": "Link copied to clipboard", "copyLink": "Copy link", "copied": "Copied!", - "viewSite": "View site", - "guests": { - "title": "Page guests", - "description": "Invite people to this page and its subpages without adding them to the workspace.", - "email": "Guest email", - "permission": "Permission", - "viewer": "Can view", - "editor": "Can edit", - "createLink": "Create invite link", - "linkCopied": "Invite link copied", - "inviteFailed": "Unable to create the invitation", - "permissionFor": "Permission for {name}", - "remove": "Remove", - "pending": "Pending", - "revoke": "Revoke" - } + "viewSite": "View site" + }, + "guests": { + "title": "Invite guests", + "description": "Invite people to this page and its subpages without adding them to the workspace.", + "email": "Guest email", + "permission": "Permission", + "viewer": "Can view", + "editor": "Can edit", + "invite": "Invite", + "linkCopied": "Invite link copied", + "inviteFailed": "Unable to create the invitation", + "updateFailed": "Unable to update the permission", + "permissionFor": "Permission for {name}", + "accessTitle": "People with access", + "loading": "Loading guests", + "empty": "No guests have access to this page.", + "remove": "Remove", + "pending": "Invitation pending", + "revoke": "Revoke" }, "rollback": { "title": "Rollback to v{version}", diff --git a/packages/i18n/src/messages/fr.json b/packages/i18n/src/messages/fr.json index 3c30ea58..e7664bf6 100644 --- a/packages/i18n/src/messages/fr.json +++ b/packages/i18n/src/messages/fr.json @@ -168,6 +168,7 @@ "viewPublishedTooltipWhenPublished": "Ouvrir le site publie", "viewPublishedTooltipWhenDraft": "Publiez d'abord votre site", "share": "Partager", + "inviteGuests": "Inviter des personnes", "deploymentHistory": "Historique des deploiements", "deployShort": "Deployer", "publishedStatus": "Publié", @@ -227,33 +228,38 @@ "deploying": "Deploiement…" }, "share": { - "title": "Parametres de partage", - "description": "Controlez qui peut voir votre site publie.", + "title": "Partager le site", + "visibilityLabel": "Visibilite du site", "visibilityPublicLabel": "Public", "visibilityPublicDescription": "Tout le monde peut voir ce site", "visibilityPrivateLabel": "Prive", "visibilityPrivateDescription": "Seuls les membres de l'equipe peuvent voir", + "loading": "Chargement des parametres de partage", "toastVisibilityUpdated": "Visibilite mise a jour", "toastVisibilityFailed": "Echec de la mise a jour de la visibilite", "toastLinkCopied": "Lien copie dans le presse-papiers", "copyLink": "Copier le lien", "copied": "Copie !", - "viewSite": "Voir le site", - "guests": { - "title": "Invites de la page", - "description": "Invitez des personnes sur cette page et ses sous-pages sans les ajouter a l'espace de travail.", - "email": "E-mail de l'invite", - "permission": "Autorisation", - "viewer": "Peut consulter", - "editor": "Peut modifier", - "createLink": "Creer un lien d'invitation", - "linkCopied": "Lien d'invitation copie", - "inviteFailed": "Impossible de creer l'invitation", - "permissionFor": "Autorisation pour {name}", - "remove": "Retirer", - "pending": "En attente", - "revoke": "Revoquer" - } + "viewSite": "Voir le site" + }, + "guests": { + "title": "Inviter des personnes", + "description": "Invitez des personnes sur cette page et ses sous-pages sans les ajouter a l'espace de travail.", + "email": "E-mail de l'invite", + "permission": "Autorisation", + "viewer": "Peut consulter", + "editor": "Peut modifier", + "invite": "Inviter", + "linkCopied": "Lien d'invitation copie", + "inviteFailed": "Impossible de creer l'invitation", + "updateFailed": "Impossible de mettre a jour l'autorisation", + "permissionFor": "Autorisation pour {name}", + "accessTitle": "Personnes ayant acces", + "loading": "Chargement des invites", + "empty": "Aucun invite n'a acces a cette page.", + "remove": "Retirer", + "pending": "Invitation en attente", + "revoke": "Revoquer" }, "rollback": { "title": "Revenir a la v{version}",