diff --git a/components/dashboard/account/ProfileSettings.module.css b/components/dashboard/account/ProfileSettings.module.css index 552daa72..7a3cfd75 100644 --- a/components/dashboard/account/ProfileSettings.module.css +++ b/components/dashboard/account/ProfileSettings.module.css @@ -160,6 +160,49 @@ cursor: default; } +/* Where the export currently stands: preparing, ready to download, or lapsed. + Sits under the request row, inside the same dashed container. */ +.exportStatus { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 12px 16px; + border-top: 1px solid var(--separator); + background-color: var(--secondary); +} + +.exportStatusText { + font-size: 0.85rem; + color: #666; +} + +.exportDownloadBtn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-shrink: 0; + background-color: transparent; + color: var(--primary-text); + border: 1px solid var(--primary-text); + padding: 6px 14px; + border-radius: 40px; + cursor: pointer; + font-weight: 600; + font-size: 0.85rem; + transition: opacity 0.2s; +} + +.exportDownloadBtn:hover { + opacity: 0.7; +} + +.exportDownloadBtn:disabled { + opacity: 0.5; + cursor: default; +} + /* Shown in the delete-account dialog when a live subscription is at stake */ .subscriptionWarning { display: flex; diff --git a/components/dashboard/account/ProfileSettings.tsx b/components/dashboard/account/ProfileSettings.tsx index f48259d6..f7e5e5ae 100644 --- a/components/dashboard/account/ProfileSettings.tsx +++ b/components/dashboard/account/ProfileSettings.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { editUserInfo, deleteUser, requestDataExport } from "@src/lib/utils/requests"; +import { editUserInfo, deleteUser, requestDataExport, downloadDataExport } from "@src/lib/utils/requests"; import { signOut } from "next-auth/react"; import { isTauri } from "@tauri-apps/api/core"; import { useRouter } from "next/navigation"; @@ -14,7 +14,8 @@ import styles from "./ProfileSettings.module.css"; import dangerStyles from "../project/DangerZone.module.css"; import modal from "../../utils/ModalBtn.module.css"; import { ApiResponse } from "@src/lib/utils/api-utils"; -import { useUser } from "@src/lib/utils/hooks"; +import { useDataExport, useUser } from "@src/lib/utils/hooks"; +import { saveBlob } from "@src/lib/utils/save-file"; import { useLocale } from "@src/context/LocaleContext"; const PRESET_COLORS = [ @@ -30,6 +31,7 @@ const PRESET_COLORS = [ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; onDangerToggle: () => void }) => { const { user, mutate } = useUser(); + const { dataExport, mutate: mutateExport } = useDataExport(); const router = useRouter(); const t = useTranslations("profile"); const tCommon = useTranslations("common"); @@ -60,10 +62,23 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; const [deleteLoading, setDeleteLoading] = useState(false); const [deleteError, setDeleteError] = useState(null); const [exportLoading, setExportLoading] = useState(false); + const [exportRequested, setExportRequested] = useState(false); + const [downloadLoading, setDownloadLoading] = useState(false); const [exportMessage, setExportMessage] = useState<{ type: "success" | "error"; text: string } | null>( null, ); + // The server is the authority on whether another export is allowed — a reload + // must not hand back a button the API would only answer with 429. `exportRequested` + // just covers the blink between the request landing and the state refetching. + const isExportBlocked = + exportLoading || exportRequested || !!dataExport?.canRequestAt || dataExport?.status === "PENDING"; + const exportExpiryDate = dataExport?.expiresAt + ? new Intl.DateTimeFormat(locale, { year: "numeric", month: "long", day: "numeric" }).format( + new Date(dataExport.expiresAt), + ) + : ""; + // Sync state when settings load useEffect(() => { if (user && !initialized) { @@ -85,18 +100,22 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; setMessage(null); }; - // GDPR data-access request: the server bundles everything in the background - // and emails a download link, so the only feedback here is "check your inbox". + // GDPR data-access request. The server bundles the zip in the background and + // keeps it for 7 days; the panel below polls until it is ready to download, + // and the email that goes out is only a notification. const handleRequestExport = async () => { - if (exportLoading) return; + if (isExportBlocked) return; setExportLoading(true); setExportMessage(null); try { const res = await requestDataExport(); if (res.ok) { + setExportRequested(true); setExportMessage({ type: "success", text: t("exportRequested") }); } else if (res.status === 409) { setExportMessage({ type: "error", text: t("exportPending") }); + } else if (res.status === 429) { + setExportMessage({ type: "error", text: t("exportThrottled") }); } else { setExportMessage({ type: "error", text: t("exportFailed") }); } @@ -104,6 +123,31 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; setExportMessage({ type: "error", text: t("exportFailed") }); } finally { setExportLoading(false); + mutateExport(); + } + }; + + // Streamed through the API rather than linked to, so the archive is only ever + // handed to a request carrying this user's session. + const handleDownloadExport = async () => { + if (!dataExport?.id || downloadLoading) return; + setDownloadLoading(true); + setExportMessage(null); + try { + const res = await downloadDataExport(dataExport.id); + if (!res.ok) throw new Error("Download failed"); + + await saveBlob(await res.blob(), "scriptio-data-export.zip", { + label: t("exportArchive"), + extension: "zip", + }); + } catch { + // Most likely the archive lapsed while the panel was open — refetch so + // the state stops offering a download that no longer exists. + setExportMessage({ type: "error", text: t("exportDownloadFailed") }); + mutateExport(); + } finally { + setDownloadLoading(false); } }; @@ -171,12 +215,31 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; + {dataExport && dataExport.status !== "NONE" && ( +
+ + {dataExport.status === "PENDING" && t("exportStatePreparing")} + {dataExport.status === "READY" && + t("exportStateReady", { date: exportExpiryDate })} + {dataExport.status === "EXPIRED" && t("exportStateExpired")} + + {dataExport.status === "READY" && ( + + )} +
+ )} {exportMessage && (
diff --git a/components/dashboard/project/ProjectSettings.tsx b/components/dashboard/project/ProjectSettings.tsx index 4430d4c0..d68a3faa 100644 --- a/components/dashboard/project/ProjectSettings.tsx +++ b/components/dashboard/project/ProjectSettings.tsx @@ -1,11 +1,12 @@ "use client"; -import { cropImageBase64 } from "@src/lib/utils/misc"; import Image from "next/image"; import { useTranslations } from "next-intl"; import { editProject } from "@src/lib/utils/requests"; import { useContext, useEffect, useState } from "react"; import { useProjectMembership, useCachedProjectInfo, useProjectIdFromUrl } from "@src/lib/utils/hooks"; +import { savePosterFromFile } from "@src/lib/posters/poster-store"; +import { usePosterUrl } from "@src/lib/posters/use-poster-url"; import { ProjectContext } from "@src/context/ProjectContext"; import UploadButton from "@components/projects/UploadButton"; import DangerZone from "./DangerZone"; @@ -28,19 +29,27 @@ const ProjectSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; const [isDirty, setDirty] = useState(false); const [selectedFile, setSelectedFile] = useState(null); - const [previewUrl, setPreviewUrl] = useState(membership?.project.poster || null); + const [filePreviewUrl, setFilePreviewUrl] = useState(null); const [loading, setLoading] = useState(false); + // The saved poster comes from the local store (works offline and for + // local-only projects); a freshly picked file previews over it until saved. + const savedPosterUrl = usePosterUrl(projectId); + const previewUrl = filePreviewUrl ?? savedPosterUrl; + // Get project data from membership or local info const projectTitle = membership?.project.title || localTitle; const projectDescription = membership?.project.description || localDescription; const projectAuthor = membership?.project.author || localAuthor; useEffect(() => { - if (!selectedFile) return; + if (!selectedFile) { + setFilePreviewUrl(null); + return; + } const objectUrl = URL.createObjectURL(selectedFile); setDirty(true); - setPreviewUrl(objectUrl); + setFilePreviewUrl(objectUrl); return () => URL.revokeObjectURL(objectUrl); }, [selectedFile]); @@ -68,19 +77,24 @@ const ProjectSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; console.error("[ProjectSettings] Failed to save local project:", error); } + // The poster is stored locally first and mirrored to the cloud from + // there, so this works the same for local-only, cloud and offline projects. + if (selectedFile) { + try { + await savePosterFromFile(projectId, selectedFile); + setSelectedFile(null); + } catch (error) { + console.error("[ProjectSettings] Failed to save poster:", error); + } + } + if (!isLocalOnly && membership) { // Also save to remote API - const body: { title: string; description: string; author: string; poster?: string } = { + await editProject(membership.project.id, { title: newTitle, description: newDescription, author: newAuthor, - }; - - if (selectedFile) { - body.poster = await cropImageBase64(selectedFile, 600, 900); - } - - await editProject(membership.project.id, body); + }); } // Sync title/author to Yjs metadata (updates title page editor) @@ -140,25 +154,23 @@ const ProjectSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; />
- {/* Poster - only show for remote projects */} - {!isLocalOnly && ( -
- -
-
- {previewUrl ? ( - Preview - ) : ( -
{t("noPoster")}
- )} -
-
-

{t("posterHelp")}

- -
+ {/* Poster */} +
+ +
+
+ {previewUrl ? ( + Preview + ) : ( +
{t("noPoster")}
+ )} +
+
+

{t("posterHelp")}

+
- )} +