Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions components/dashboard/account/ProfileSettings.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
77 changes: 70 additions & 7 deletions components/dashboard/account/ProfileSettings.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 = [
Expand All @@ -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");
Expand Down Expand Up @@ -60,10 +62,23 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean;
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(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) {
Expand All @@ -85,25 +100,54 @@ 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") });
}
} catch {
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);
}
};

Expand Down Expand Up @@ -171,12 +215,31 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean;
<button
className={styles.exportBtn}
onClick={handleRequestExport}
disabled={exportLoading}
disabled={isExportBlocked}
>
<Download size={16} />
{exportLoading ? t("exportRequesting") : t("exportBtn")}
</button>
</div>
{dataExport && dataExport.status !== "NONE" && (
<div className={styles.exportStatus}>
<span className={styles.exportStatusText}>
{dataExport.status === "PENDING" && t("exportStatePreparing")}
{dataExport.status === "READY" &&
t("exportStateReady", { date: exportExpiryDate })}
{dataExport.status === "EXPIRED" && t("exportStateExpired")}
</span>
{dataExport.status === "READY" && (
<button
className={styles.exportDownloadBtn}
onClick={handleDownloadExport}
disabled={downloadLoading}
>
<Download size={16} />
{downloadLoading ? t("exportDownloading") : t("exportDownload")}
</button>
)}
</div>
)}
</div>
{exportMessage && (
<div className={`${styles.message} ${styles[exportMessage.type]}`}>
Expand Down
70 changes: 41 additions & 29 deletions components/dashboard/project/ProjectSettings.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -28,19 +29,27 @@ const ProjectSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean;

const [isDirty, setDirty] = useState<boolean>(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(membership?.project.poster || null);
const [filePreviewUrl, setFilePreviewUrl] = useState<string | null>(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]);

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -140,25 +154,23 @@ const ProjectSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean;
/>
</div>

{/* Poster - only show for remote projects */}
{!isLocalOnly && (
<div className={styles.formGroup}>
<label className={form.label}>{t("posterLabel")}</label>
<div className={styles.posterUploadArea}>
<div className={styles.posterPreview}>
{previewUrl ? (
<Image src={previewUrl} alt="Preview" width={120} height={180} />
) : (
<div className={styles.posterPlaceholder}>{t("noPoster")}</div>
)}
</div>
<div className={styles.uploadControls}>
<p className={styles.helpText}>{t("posterHelp")}</p>
<UploadButton setSelectedFile={setSelectedFile} selectedFile={selectedFile} />
</div>
{/* Poster */}
<div className={styles.formGroup}>
<label className={form.label}>{t("posterLabel")}</label>
<div className={styles.posterUploadArea}>
<div className={styles.posterPreview}>
{previewUrl ? (
<Image src={previewUrl} alt="Preview" width={120} height={180} />
) : (
<div className={styles.posterPlaceholder}>{t("noPoster")}</div>
)}
</div>
<div className={styles.uploadControls}>
<p className={styles.helpText}>{t("posterHelp")}</p>
<UploadButton setSelectedFile={setSelectedFile} selectedFile={selectedFile} />
</div>
</div>
)}
</div>

<div className={styles.formActions}>
<button type="submit" className={`${styles.formBtn}`} disabled={loading || !isDirty}>
Expand Down
16 changes: 16 additions & 0 deletions components/editor/EditorPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@
display: block;
}

@media (pointer: coarse) {
.container,
.editor_wrapper,
.page_shift {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}

.container :global(.ProseMirror) {
-webkit-user-select: text;
user-select: text;
-webkit-touch-callout: default;
}
}

.editor_shadow {
position: sticky;
top: 0;
Expand Down
8 changes: 5 additions & 3 deletions components/project/ProjectWorkspace.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,11 @@
}
}

.right_sidebar_toggle:hover {
opacity: 1;
background-color: var(--tertiary-hover);
@media (hover: hover) {
.right_sidebar_toggle:hover {
opacity: 1;
background-color: var(--tertiary-hover);
}
}

/* Dim + tap-to-close layer behind the phone sidebar drawers. Sits above the
Expand Down
8 changes: 5 additions & 3 deletions components/project/SplitPanelContainer.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,11 @@
border: none;
}

.panel_switcher_btn:hover {
opacity: 1;
background-color: var(--secondary-hover);
@media (hover: hover) {
.panel_switcher_btn:hover {
opacity: 1;
background-color: var(--secondary-hover);
}
}

/* Touch devices (phone + iPad): enlarge to a comfortable tap target, matching
Expand Down
8 changes: 5 additions & 3 deletions components/projects/ProjectItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Image from "next/image";
import item from "./ProjectItem.module.css";
import { useAppNavigation } from "@src/lib/utils/navigation";
import { ProjectMembershipPayload } from "@src/server/repository/project-repository";
import { usePosterUrl } from "@src/lib/posters/use-poster-url";
import { CloudCheck, HardDrive } from "lucide-react";

type Props = {
Expand All @@ -18,6 +19,9 @@ const ProjectItem = ({ project, isLocalOnly = false }: Props) => {
const t = useTranslations("projects");
const { goToProject } = useAppNavigation();
const tDates = useTranslations("dates");
// Resolved from the local poster store, so local-only projects show a poster
// and cloud ones keep showing theirs offline.
const posterUrl = usePosterUrl(project.id, !isLocalOnly);
const elapsedDays = getElapsedDaysFrom(project.updatedAt);
const lastUpdated =
elapsedDays === 0
Expand All @@ -30,9 +34,7 @@ const ProjectItem = ({ project, isLocalOnly = false }: Props) => {
? tDates("monthsAgo", { months: Math.round(elapsedDays / 30) })
: tDates("moreThanYearAgo");

let posterPath;
if (project.poster) posterPath = project.poster;
else posterPath = "/images/default-poster.png";
const posterPath = posterUrl ?? "/images/default-poster.png";

const storageLabel = isLocalOnly ? t("item.localOnly") : t("item.syncedToCloud");
const StorageIcon = isLocalOnly ? HardDrive : CloudCheck;
Expand Down
Loading
Loading