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
19 changes: 19 additions & 0 deletions components/board/BoardCanvas.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,25 @@
font-variant-numeric: tabular-nums;
}

.asset_error {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
z-index: 12;
display: flex;
align-items: center;
gap: 8px;
max-width: 80%;
height: 36px;
padding: 0 14px;
border-radius: 16px;
background-color: var(--error);
color: #fff;
font-size: 13px;
user-select: none;
}

.recording_stop {
display: flex;
align-items: center;
Expand Down
63 changes: 60 additions & 3 deletions components/board/BoardCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import { v7 as uuidv7 } from "uuid";
import { Trash2, Plus, Minus, Copy, ListTree, Mic, Square } from "lucide-react";
import { useTranslations } from "next-intl";
import { DEFAULT_ITEM_COLORS } from "@src/lib/utils/colors";
import { importImageFile, importAudioFile } from "@src/lib/assets/asset-store";
import { importImageFile, importAudioFile, syncAssetToCloud } from "@src/lib/assets/asset-store";
import { CloudQuotaError } from "@src/lib/assets/cloud-asset-sync";
import { scheduleAssetGc } from "@src/lib/assets/asset-gc";
import { useAudioRecorder } from "./use-audio-recorder";

Expand Down Expand Up @@ -56,6 +57,9 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string })
const [isPanning, setIsPanning] = useState(false);
const [isDraggingFile, setIsDraggingFile] = useState(false);
const [isSnapping, setIsSnapping] = useState(true);
/** Transient banner shown when an asset can't be saved (e.g. cloud quota). */
const [assetError, setAssetError] = useState<string | null>(null);
const assetErrorTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const recorder = useAudioRecorder();
const [prevIsVisible, setPrevIsVisible] = useState(isVisible);
if (prevIsVisible !== isVisible) {
Expand Down Expand Up @@ -527,6 +531,50 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string })
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setIsDraggingFile(false);
}, []);

// Show a transient error banner (auto-dismissed). Used when an asset can't be
// persisted, e.g. the owner is out of cloud storage.
const showAssetError = useCallback((message: string) => {
setAssetError(message);
if (assetErrorTimer.current) clearTimeout(assetErrorTimer.current);
assetErrorTimer.current = setTimeout(() => setAssetError(null), 4000);
}, []);

useEffect(() => () => {
if (assetErrorTimer.current) clearTimeout(assetErrorTimer.current);
}, []);

// Remove cards by id (used to roll back a card whose asset can't be saved).
const removeCards = useCallback(
(ids: Set<string>) => {
const next = cardsRef.current.filter((c) => !ids.has(c.id));
cardsRef.current = next; // keep the ref current so concurrent removals don't race
setCards(next);
saveCards(next);
},
[saveCards],
);

// Upload the new cards' assets to the cloud in the background, so the cards
// appear instantly (the bytes are already cached locally and render offline).
// If an upload is rejected for quota, roll back that card and explain why.
const syncCreatedAssets = useCallback(
(createdCards: BoardCardData[], pid: string) => {
for (const card of createdCards) {
if (!card.assetId) continue;
const cardId = card.id;
void syncAssetToCloud(pid, card.assetId).catch((err) => {
if (err instanceof CloudQuotaError) {
removeCards(new Set([cardId]));
showAssetError(t("storageLimitReached"));
} else {
console.error("[BoardCanvas] cloud asset upload failed:", err);
}
});
}
},
[removeCards, showAssetError, t],
);

// Drop image files → store each in IndexedDB (deduped) and drop an image
// card referencing its hash at the cursor.
const handleDrop = useCallback(
Expand Down Expand Up @@ -589,8 +637,11 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string })
const newCards = [...cardsRef.current, ...created];
setCards(newCards);
saveCards(newCards);

// Upload to the cloud in the background (cards already show locally).
syncCreatedAssets(created, projectId);
},
[isReadOnly, projectId, offset, scale, saveCards],
[isReadOnly, projectId, offset, scale, saveCards, syncCreatedAssets],
);

// Create a text card at the given canvas-space coords (from the canvas menu).
Expand Down Expand Up @@ -651,10 +702,13 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string })
const newCards = [...cardsRef.current, newCard];
setCards(newCards);
saveCards(newCards);

// Upload to the cloud in the background (card already shows locally).
syncCreatedAssets([newCard], projectId);
} catch (err) {
console.error("[BoardCanvas] Failed to store recording:", err);
}
}, [recorder, projectId, saveCards]);
}, [recorder, projectId, saveCards, syncCreatedAssets]);

// Right-clicking empty canvas opens a menu (create card / record audio).
// Cards and arrows have their own menus, so bail when the click landed on one.
Expand Down Expand Up @@ -1206,6 +1260,9 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string })
)}
</div>

{/* Transient asset error (e.g. cloud storage limit reached) */}
{assetError && <div className={styles.asset_error}>{assetError}</div>}

{/* Recording indicator */}
{recorder.isRecording && (
<div className={styles.recording_indicator}>
Expand Down
5 changes: 4 additions & 1 deletion components/dashboard/DashboardModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import CollaboratorsSettings from "./project/CollaboratorsSettings";

import styles from "./DashboardModal.module.css";
import ExportProject from "./project/ExportProject";
import { CreditCard, FileDown, Folder, Globe, Keyboard, Lock, Palette, PanelsTopLeft, User, Users, X } from "lucide-react";
import { CreditCard, FileDown, Folder, Globe, HardDrive, Keyboard, Lock, Palette, PanelsTopLeft, User, Users, X } from "lucide-react";
import { useTranslations } from "next-intl";
import KeybindsSettings from "./preferences/KeybindsSettings";
import AppearanceSettings from "./preferences/AppearanceSettings";
Expand All @@ -20,6 +20,7 @@ import ProfileSettings from "./account/ProfileSettings";
import SubscriptionSettings from "./account/SubscriptionSettings";
import LayoutSettings from "./project/LayoutSettings";
import ProductionSettings from "./project/ProductionSettings";
import StorageSettings from "./project/StorageSettings";
import DashboardAuth from "./account/DashboardAuth";
import AboutSettings from "./AboutSettings";

Expand All @@ -36,6 +37,7 @@ const DashboardModal = () => {
{ id: "Layout", label: t("tabs.Layout"), icon: <PanelsTopLeft size={18} /> },
{ id: "Production", label: t("tabs.Production"), icon: <Lock size={18} /> },
{ id: "Export", label: t("tabs.Export"), icon: <FileDown size={18} /> },
{ id: "Storage", label: t("tabs.Storage"), icon: <HardDrive size={18} /> },
{ id: "Collaborators", label: t("tabs.Collaborators"), icon: <Users size={18} /> },
],
}), [t]);
Expand Down Expand Up @@ -138,6 +140,7 @@ const DashboardModal = () => {
{isInProject && activeTab === "Layout" && <LayoutSettings />}
{isInProject && activeTab === "Production" && <ProductionSettings />}
{isInProject && activeTab === "Export" && <ExportProject />}
{isInProject && activeTab === "Storage" && <StorageSettings />}
{isInProject && activeTab === "Collaborators" && <CollaboratorsSettings />}
{/* Preferences tabs */}
{activeTab === "Keybinds" && <KeybindsSettings />}
Expand Down
1 change: 1 addition & 0 deletions components/dashboard/DashboardSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type Category =
| "Layout"
| "Production"
| "Export"
| "Storage"
| "Collaborators"
| "Profile"
| "Subscription"
Expand Down
40 changes: 22 additions & 18 deletions components/dashboard/preferences/LanguageSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Check, ChevronDown, Download, Loader2, Plus, X } from "lucide-react";
import { Check, ChevronDown, Download, Loader2, Plus, SpellCheck, X } from "lucide-react";
import form from "./../../utils/Form.module.css";
import sharedStyles from "../project/ProjectSettings.module.css";
import styles from "./SpellcheckSettings.module.css";
Expand All @@ -10,7 +10,11 @@ import { useLocale } from "@src/context/LocaleContext";
import { useSettings } from "@src/lib/utils/hooks";
import { useSpellcheck } from "@src/context/SpellcheckContext";
import { ProjectContext } from "@src/context/ProjectContext";
import { DICTIONARY_CATALOG, formatDictionarySize } from "@src/lib/spellcheck/spellcheck-dictionaries";
import {
BUILTIN_DICTIONARY_CODE,
DICTIONARY_CATALOG,
formatDictionarySize,
} from "@src/lib/spellcheck/spellcheck-dictionaries";
import Dropdown, { DropdownOption } from "@components/utils/Dropdown";
import { useTranslations } from "next-intl";

Expand Down Expand Up @@ -72,12 +76,8 @@ const LanguageSettings = () => {
);

const spellcheckOptions: DropdownOption[] = useMemo(() => {
const noneOption: DropdownOption = {
value: "none",
label: t("spellcheckNone"),
};

const dictOptions: DropdownOption[] = DICTIONARY_CATALOG.map((dict) => {
return DICTIONARY_CATALOG.map((dict) => {
const isBuiltin = dict.code === BUILTIN_DICTIONARY_CODE;
const installed = installedDictionaries.find((d) => d.code === dict.code);
const isDownloading = downloadProgress?.code === dict.code;

Expand All @@ -87,7 +87,12 @@ const LanguageSettings = () => {
<div className={styles.dictOption}>
<span>{dict.name}</span>
<span className={styles.dictMeta}>
{isDownloading ? (
{isBuiltin ? (
<>
<span className={styles.size}>{t("spellcheckBuiltin")}</span>
<Check size={14} className={styles.checkmark} />
</>
) : isDownloading ? (
<Loader2 size={14} className={styles.spinner} />
) : installed ? (
<>
Expand All @@ -100,20 +105,19 @@ const LanguageSettings = () => {
</span>
</div>
),
triggerLabel: dict.name,
triggerLabel: (
<span className={styles.triggerLabel}>
<SpellCheck size={14} className={styles.triggerIcon} />
{dict.name}
</span>
),
};
});

return [noneOption, ...dictOptions];
}, [installedDictionaries, downloadProgress, t]);

const handleSpellcheckChange = useCallback(
(value: string) => {
if (value === "none") {
setSpellcheckLang(null);
return;
}
const isInstalled = installedDictionaries.some((d) => d.code === value);
const isInstalled = value === BUILTIN_DICTIONARY_CODE || installedDictionaries.some((d) => d.code === value);
if (isInstalled) {
setSpellcheckLang(value);
} else {
Expand All @@ -140,7 +144,7 @@ const LanguageSettings = () => {
<label className={form.label}>{t("spellcheckLabel")}</label>
<p className={sharedStyles.helpText}>{t("spellcheckHelpText")}</p>
<Dropdown
value={spellcheckLang ?? "none"}
value={spellcheckLang ?? "en"}
onChange={handleSpellcheckChange}
options={spellcheckOptions}
className={sharedStyles.input}
Expand Down
12 changes: 12 additions & 0 deletions components/dashboard/preferences/SpellcheckSettings.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@
gap: 8px;
}

/* Selected language shown in the collapsed dropdown trigger, with a leading icon. */
.triggerLabel {
display: inline-flex;
align-items: center;
gap: 8px;
}

.triggerIcon {
color: var(--secondary-text);
flex-shrink: 0;
}

.dictMeta {
display: flex;
align-items: center;
Expand Down
4 changes: 2 additions & 2 deletions components/dashboard/project/ExportProject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { BaseExportOptions } from "@src/lib/adapters/screenplay-adapter";
import Dropdown, { DropdownOption } from "@components/utils/Dropdown";
import { PDFExportOptions } from "@src/lib/adapters/pdf/pdf-adapter";
import { ScriptioExportOptions } from "@src/lib/adapters/scriptio/scriptio-adapter";
import { importFileIntoProject } from "@src/lib/import/import-project";
import { importFileIntoProject, getSupportedImportExtensions } from "@src/lib/import/import-project";

export enum ExportFormat {
PDF = "pdf",
Expand Down Expand Up @@ -156,7 +156,7 @@ const ExportProject = () => {
type="file"
ref={fileInputRef}
onChange={handleFileImport}
accept=".fountain,.txt,.fdx,.scriptio"
accept={getSupportedImportExtensions()}
style={{ display: "none" }}
/>

Expand Down
Loading
Loading