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
58 changes: 58 additions & 0 deletions apps/web/src/features/board/boardOwnership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { SupabaseClient } from "@supabase/supabase-js";

// Ensure a `boards` row exists for this id and claim ownership when it is
// unowned and we are signed in. Idempotent. Returns whether the current user
// owns the board, which gates the named-snapshot Save/Restore UI (owner-only per
// the RLS policy in 0001_init.sql).
//
// Safe to call with a null client (local-only mode) or signed out — both yield
// { owned: false } without throwing, so PDF export and undo still work.
export async function ensureBoardOwnership(
client: SupabaseClient | null,
boardId: string,
): Promise<{ owned: boolean }> {
if (!client) return { owned: false };

const { data: auth } = await client.auth.getUser();
const userId = auth.user?.id ?? null;

// SELECT is allowed for any public board.
const { data: existing } = await client
.from("boards")
.select("id, owner_id")
.eq("id", boardId)
.maybeSingle();

if (!userId) return { owned: false };

if (!existing) {
// boards_insert_any allows check(true); claim ownership on first load.
const { error } = await client
.from("boards")
.insert({ id: boardId, owner_id: userId });
if (error) {
// A racing peer may have inserted first → re-read to settle ownership.
const { data: row } = await client
.from("boards")
.select("owner_id")
.eq("id", boardId)
.maybeSingle();
return { owned: row?.owner_id === userId };
}
return { owned: true };
}

if (existing.owner_id === userId) return { owned: true };

if (existing.owner_id === null) {
// boards_update_owner allows update while owner_id is null.
const { error } = await client
.from("boards")
.update({ owner_id: userId })
.eq("id", boardId)
.is("owner_id", null);
return { owned: !error };
}

return { owned: false }; // owned by someone else
}
71 changes: 71 additions & 0 deletions apps/web/src/features/board/snapshotsApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { SupabaseClient } from "@supabase/supabase-js";

export interface NamedSnapshot {
id: string;
label: string;
createdAt: string;
}

// PostgREST reads/writes a `bytea` column as a hex string in the "\x.." format.
// Encoding the Yjs update as hex avoids needing a server-side base64 decode RPC
// and works against the stock schema.
function bytesToHexBytea(bytes: Uint8Array): string {
let hex = "\\x";
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
return hex;
}

function hexByteaToBytes(s: string): Uint8Array {
const hex = s.startsWith("\\x") ? s.slice(2) : s;
const out = new Uint8Array(hex.length >> 1);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return out;
}

export async function saveNamedSnapshot(
client: SupabaseClient,
boardId: string,
label: string,
bytes: Uint8Array,
): Promise<void> {
const { error } = await client.from("snapshots").insert({
board_id: boardId,
kind: "named",
label,
ydoc: bytesToHexBytea(bytes),
});
if (error) throw error;
}

export async function listNamedSnapshots(
client: SupabaseClient,
boardId: string,
): Promise<NamedSnapshot[]> {
const { data, error } = await client
.from("snapshots")
.select("id, label, created_at")
.eq("board_id", boardId)
.eq("kind", "named")
.order("created_at", { ascending: false });
if (error) throw error;
return (data ?? []).map((r) => ({
id: r.id as string,
label: (r.label as string | null) ?? "Untitled",
createdAt: r.created_at as string,
}));
}

export async function fetchSnapshotBytes(
client: SupabaseClient,
id: string,
): Promise<Uint8Array> {
const { data, error } = await client
.from("snapshots")
.select("ydoc")
.eq("id", id)
.single();
if (error || !data) throw error ?? new Error("snapshot not found");
return hexByteaToBytes(data.ydoc as string);
}
78 changes: 77 additions & 1 deletion apps/web/src/features/canvas/AppMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { useRef, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import type { SupabaseClient } from "@supabase/supabase-js";
import { GlassPanel, Icon, Popover, useTheme, type IconName } from "@notux/ui";
import {
exportBoardToPdf,
useAssetStore,
useCommandStore,
usePageStore,
useShapeStore,
useToolStore,
} from "@notux/canvas";
import { SnapshotsPanel } from "./SnapshotsPanel";

interface AppMenuProps {
boardId: string;
client: SupabaseClient | null;
owned: boolean;
}

interface MenuItemProps {
icon?: IconName;
Expand Down Expand Up @@ -41,7 +50,7 @@ function MenuSection({ title, children }: { title: string; children: ReactNode }
);
}

export function AppMenu() {
export function AppMenu({ boardId, client, owned }: AppMenuProps) {
const navigate = useNavigate();
const { theme, toggle: toggleTheme } = useTheme();

Expand All @@ -62,6 +71,9 @@ export function AppMenu() {

const [menuOpen, setMenuOpen] = useState(false);
const [pagesOpen, setPagesOpen] = useState(false);
const [snapshotsOpen, setSnapshotsOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [exporting, setExporting] = useState(false);
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [overIdx, setOverIdx] = useState<number | null>(null);

Expand Down Expand Up @@ -114,6 +126,21 @@ export function AppMenu() {
useToolStore.getState().setSelection(ids);
}

async function runExport(scope: "current" | "all") {
setExportOpen(false);
const all = usePageStore.getState().pages;
const active = usePageStore.getState().activePageId;
const pages = scope === "current" ? all.filter((p) => p.id === active) : all;
setExporting(true);
try {
await exportBoardToPdf({ pages, filename: "board.pdf" });
} catch (e) {
console.error("PDF export failed:", e);
} finally {
setExporting(false);
}
}

function commitDrop() {
if (dragIdx !== null && overIdx !== null && dragIdx !== overIdx) {
reorderPage(dragIdx, overIdx);
Expand Down Expand Up @@ -189,6 +216,23 @@ export function AppMenu() {
disabled={!canImport}
onClick={() => run(() => fileRef.current?.click())}
/>
<MenuItem
icon="download"
label={exporting ? "Exporting…" : "Export as PDF…"}
disabled={exporting}
onClick={() => {
setMenuOpen(false);
setExportOpen(true);
}}
/>
<MenuItem
icon="history"
label="Snapshots…"
onClick={() => {
setMenuOpen(false);
setSnapshotsOpen(true);
}}
/>
</MenuSection>

<MenuSection title="Edit">
Expand Down Expand Up @@ -315,6 +359,38 @@ export function AppMenu() {
</div>
</Popover>

{/* Export scope chooser */}
<Popover
open={exportOpen}
onClose={() => setExportOpen(false)}
anchorRef={menuBtnRef}
placement="bottom"
className="menu-popover"
>
<div className="menu">
<div className="menu__section-title">Export as PDF</div>
<MenuItem
icon="pages"
label="Current page"
onClick={() => void runExport("current")}
/>
<MenuItem
icon="download"
label="All pages"
onClick={() => void runExport("all")}
/>
</div>
</Popover>

<SnapshotsPanel
open={snapshotsOpen}
onClose={() => setSnapshotsOpen(false)}
anchorRef={menuBtnRef}
boardId={boardId}
client={client}
owned={owned}
/>

<input
ref={fileRef}
type="file"
Expand Down
Loading
Loading