init frontend

This commit is contained in:
Yuzhong Zhang
2025-07-05 23:22:48 +08:00
parent 94953a5eac
commit 602f4629ff
771 changed files with 194268 additions and 1 deletions
@@ -0,0 +1,166 @@
import {
CanvasData,
CanvasMetadata,
dehydrateCanvasData,
hydrateCanvasData,
IStorageAdapter,
} from "../storage";
import { nanoid } from "nanoid";
import { jwtDecode } from "jwt-decode";
export class AuthError extends Error {
constructor(message: string) {
super(message);
this.name = "AuthError";
}
}
const API_BASE_URL = "/api/v2/kv";
interface AppJwtPayload {
sub: string;
}
// The backend uses the GitHub user ID as the subject in the JWT.
// We can decode the token to get this ID for frontend purposes.
function getUserIdFromJwt(token: string): number | null {
try {
const decodedToken = jwtDecode<AppJwtPayload>(token);
if (decodedToken && decodedToken.sub) {
const userId = parseInt(decodedToken.sub, 10);
if (!isNaN(userId) && Number.isInteger(userId)) {
return userId;
}
}
return null;
} catch (e) {
console.error("Failed to decode JWT", e);
return null;
}
}
const getAuthHeaders = () => {
const token = localStorage.getItem("token");
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
};
export class BackendStorageAdapter implements IStorageAdapter {
async listCanvases(): Promise<CanvasMetadata[]> {
const response = await fetch(API_BASE_URL, {
method: "GET",
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
// For list, we can just return an empty array as if the user has no canvases.
// This prevents an error popup when a logged-out user opens the app.
return [];
}
throw new Error(`Failed to list canvases: ${response.statusText}`);
}
// Backend doesn't send userId, so we enrich the data here.
const canvases: Omit<CanvasMetadata, "userId">[] = await response.json();
const token = localStorage.getItem("token");
if (!token) {
return [];
}
const userId = getUserIdFromJwt(token);
if (!userId) {
console.error("Could not determine userId from token.");
return [];
}
return canvases.map((canvas) => ({ ...canvas, userId }));
}
async loadCanvas(id: string): Promise<CanvasData | null> {
const response = await fetch(`${API_BASE_URL}/${id}`, {
method: "GET",
headers: getAuthHeaders(),
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new AuthError("User is not authenticated");
}
throw new Error(`Failed to load canvas: ${response.statusText}`);
}
const rawData = await response.json();
return hydrateCanvasData(rawData);
}
async saveCanvas(id: string, data: CanvasData): Promise<void> {
const saveData = dehydrateCanvasData(data);
const response = await fetch(`${API_BASE_URL}/${id}`, {
method: "PUT",
headers: getAuthHeaders(),
body: JSON.stringify(saveData),
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new AuthError("User is not authenticated");
}
throw new Error(`Failed to save canvas: ${response.statusText}`);
}
}
async createCanvas(data: CanvasData): Promise<CanvasMetadata> {
const newId = nanoid();
const token = localStorage.getItem("token");
if (!token) {
throw new Error("Authentication token not found.");
}
const userId = getUserIdFromJwt(token);
if (!userId) {
throw new Error("Could not parse user ID from token.");
}
await this.saveCanvas(newId, data);
return {
id: newId,
name: data.appState?.name || "Untitled",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
userId,
};
}
async deleteCanvas(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/${id}`, {
method: "DELETE",
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new AuthError("User is not authenticated");
}
throw new Error(`Failed to delete canvas: ${response.statusText}`);
}
}
async renameCanvas(id: string, newName: string): Promise<void> {
const canvasData = await this.loadCanvas(id);
if (!canvasData) {
throw new Error("Canvas not found, cannot rename.");
}
const updatedData: CanvasData = {
...canvasData,
appState: {
...canvasData.appState,
name: newName,
},
};
await this.saveCanvas(id, updatedData);
}
}
@@ -0,0 +1,230 @@
import { generateThumbnail } from "../thumbnail";
import {
CanvasData,
CanvasMetadata,
dehydrateCanvasData,
hydrateCanvasData,
IStorageAdapter,
} from "../storage";
const KEY_PREFIX_METADATA = "excalidraw-canvas-meta:";
const KEY_PREFIX_DATA = "excalidraw-canvas-data:";
export class CloudflareKVAdapter implements IStorageAdapter {
private kv_url: string;
private apiToken: string;
private baseUrl: string;
constructor(config: { kv_url: string; apiToken: string }) {
this.kv_url = config.kv_url;
this.apiToken = config.apiToken;
this.baseUrl = `https://${this.kv_url}`;
}
private getAuthHeaders() {
return {
Authorization: `Bearer ${this.apiToken}`,
"Content-Type": "application/json",
};
}
async listCanvases(): Promise<CanvasMetadata[]> {
const response = await fetch(
`${this.baseUrl}/keys?prefix=${KEY_PREFIX_METADATA}`,
{
headers: this.getAuthHeaders(),
},
);
if (!response.ok) {
console.error(
"Failed to list canvases from Cloudflare KV",
await response.text(),
);
throw new Error("Failed to list canvases from Cloudflare KV.");
}
const { result: keys } = (await response.json()) as {
result: { name: string }[];
};
if (!keys || keys.length === 0) {
return [];
}
const metadataPromises = keys.map((key) =>
this.getCanvasMetadata(key.name),
);
const metadata = await Promise.all(metadataPromises);
// Filter out any nulls that might have occurred if a key was deleted between listing and fetching
return metadata.filter((m): m is CanvasMetadata => m !== null);
}
private async getCanvasMetadata(key: string): Promise<CanvasMetadata | null> {
const response = await fetch(`${this.baseUrl}/values/${key}`, {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 404) {
return null;
}
console.error(
`Failed to fetch metadata for key ${key}`,
await response.text(),
);
return null;
}
return response.json();
}
async loadCanvas(id: string): Promise<CanvasData | null> {
const key = `${KEY_PREFIX_DATA}${id}`;
const response = await fetch(`${this.baseUrl}/values/${key}`, {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 404) {
return null;
}
throw new Error(`Failed to load canvas ${id} from Cloudflare KV.`);
}
const rawData = await response.json();
return hydrateCanvasData(rawData);
}
async saveCanvas(id: string, data: CanvasData): Promise<void> {
const metadataKey = `${KEY_PREFIX_METADATA}${id}`;
const dataKey = `${KEY_PREFIX_DATA}${id}`;
const existingMetadata = await this.getCanvasMetadata(metadataKey);
if (!existingMetadata) {
throw new Error("Canvas metadata not found. Cannot save.");
}
const thumbnail = await generateThumbnail(
data.elements,
data.appState,
data.files,
);
const updatedMetadata: CanvasMetadata = {
...existingMetadata,
name: data.appState.name || existingMetadata.name,
updatedAt: new Date().toISOString(),
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
};
const dehydratedData = dehydrateCanvasData(data);
const bulkPayload = [
{ key: metadataKey, value: JSON.stringify(updatedMetadata) },
{ key: dataKey, value: JSON.stringify(dehydratedData) },
];
const response = await fetch(`${this.baseUrl}/bulk`, {
method: "PUT",
headers: this.getAuthHeaders(),
body: JSON.stringify(bulkPayload),
});
if (!response.ok) {
throw new Error(`Failed to save canvas ${id} to Cloudflare KV.`);
}
}
async createCanvas(data: CanvasData): Promise<CanvasMetadata> {
const newId = window.crypto.randomUUID();
const now = new Date().toISOString();
const thumbnail = await generateThumbnail(
data.elements,
data.appState,
data.files,
);
const newMetadata: CanvasMetadata = {
id: newId,
name: data.appState.name || "Untitled Canvas",
createdAt: now,
updatedAt: now,
// Assuming userId comes from a context, hardcoding for now
userId: 0,
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
};
const metadataKey = `${KEY_PREFIX_METADATA}${newId}`;
const dataKey = `${KEY_PREFIX_DATA}${newId}`;
const dehydratedData = dehydrateCanvasData(data);
const bulkPayload = [
{ key: metadataKey, value: JSON.stringify(newMetadata) },
{ key: dataKey, value: JSON.stringify(dehydratedData) },
];
const response = await fetch(`${this.baseUrl}/bulk`, {
method: "PUT",
headers: this.getAuthHeaders(),
body: JSON.stringify(bulkPayload),
});
if (!response.ok) {
throw new Error("Failed to create canvas in Cloudflare KV.");
}
return newMetadata;
}
async deleteCanvas(id: string): Promise<void> {
const keysToDelete = [
`${KEY_PREFIX_METADATA}${id}`,
`${KEY_PREFIX_DATA}${id}`,
];
const response = await fetch(`${this.baseUrl}/bulk`, {
method: "DELETE",
headers: this.getAuthHeaders(),
body: JSON.stringify(keysToDelete),
});
if (!response.ok) {
throw new Error(`Failed to delete canvas ${id} from Cloudflare KV.`);
}
}
async renameCanvas(id: string, newName: string): Promise<void> {
const metadataKey = `${KEY_PREFIX_METADATA}${id}`;
const dataKey = `${KEY_PREFIX_DATA}${id}`;
const [metadata, data] = await Promise.all([
this.getCanvasMetadata(metadataKey),
this.loadCanvas(id),
]);
if (!metadata) {
throw new Error("Canvas metadata not found. Cannot rename.");
}
if (!data) {
throw new Error("Canvas data not found. Cannot rename.");
}
const updatedMetadata: CanvasMetadata = { ...metadata, name: newName };
const updatedData: CanvasData = {
...data,
appState: { ...data.appState, name: newName },
};
const dehydratedData = dehydrateCanvasData(updatedData);
const bulkPayload = [
{ key: metadataKey, value: JSON.stringify(updatedMetadata) },
{ key: dataKey, value: JSON.stringify(dehydratedData) },
];
const response = await fetch(`${this.baseUrl}/bulk`, {
method: "PUT",
headers: this.getAuthHeaders(),
body: JSON.stringify(bulkPayload),
});
if (!response.ok) {
throw new Error(`Failed to rename canvas ${id} in Cloudflare KV.`);
}
}
}
@@ -0,0 +1,94 @@
import { createStore, set, get, del, entries } from "idb-keyval";
import { CanvasData, CanvasMetadata, IStorageAdapter } from "../storage";
import { generateThumbnail } from "../thumbnail";
const metadataStore = createStore("excalidraw-canvases-metadata", "metadata");
const dataStore = createStore("excalidraw-canvases-data", "data");
export class IndexedDBStorageAdapter implements IStorageAdapter {
async listCanvases(): Promise<CanvasMetadata[]> {
const allEntries = await entries<string, CanvasMetadata>(metadataStore);
return allEntries.map(([, metadata]) => metadata);
}
async loadCanvas(id: string): Promise<CanvasData | null> {
const data = await get<CanvasData>(id, dataStore);
return data === undefined ? null : data;
}
async saveCanvas(id: string, data: CanvasData): Promise<void> {
const existingMetadata = await get<CanvasMetadata>(id, metadataStore);
if (!existingMetadata) {
throw new Error("Canvas metadata not found. Cannot save.");
}
const thumbnail = await generateThumbnail(
data.elements,
data.appState,
data.files,
);
const updatedMetadata: CanvasMetadata = {
...existingMetadata,
name: data.appState.name || existingMetadata.name,
updatedAt: new Date().toISOString(),
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
};
await set(id, updatedMetadata, metadataStore);
await set(id, data, dataStore);
}
async createCanvas(data: CanvasData): Promise<CanvasMetadata> {
const newId = window.crypto.randomUUID();
const now = new Date().toISOString();
const thumbnail = await generateThumbnail(
data.elements,
data.appState,
data.files,
);
const newMetadata: CanvasMetadata = {
id: newId,
name: data.appState.name || "Untitled Canvas",
createdAt: now,
updatedAt: now,
// UserID is 0 for local, non-synced canvases
userId: 0,
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
};
await set(newId, newMetadata, metadataStore);
await set(newId, data, dataStore);
return newMetadata;
}
async deleteCanvas(id: string): Promise<void> {
await del(id, metadataStore);
await del(id, dataStore);
}
async renameCanvas(id: string, newName: string): Promise<void> {
// Update metadata
const existingMetadata = await get<CanvasMetadata>(id, metadataStore);
if (!existingMetadata) {
throw new Error("Canvas metadata not found. Cannot rename.");
}
await set(id, { ...existingMetadata, name: newName }, metadataStore);
// Update canvas data
const existingData = await get<CanvasData>(id, dataStore);
if (!existingData) {
// This should not happen if metadata exists, but as a safeguard:
throw new Error("Canvas data not found. Cannot rename.");
}
await set(
id,
{
...existingData,
appState: { ...existingData.appState, name: newName },
},
dataStore,
);
}
}
@@ -0,0 +1,197 @@
import {
S3Client,
ListObjectsV2Command,
GetObjectCommand,
PutObjectCommand,
DeleteObjectsCommand,
} from "@aws-sdk/client-s3";
import {
CanvasData,
CanvasMetadata,
dehydrateCanvasData,
hydrateCanvasData,
IStorageAdapter,
} from "../storage";
import { generateThumbnail } from "../thumbnail";
const KEY_PREFIX_METADATA = "excalidraw-canvas-meta-";
const KEY_PREFIX_DATA = "excalidraw-canvas-data-";
export class S3StorageAdapter implements IStorageAdapter {
private s3: S3Client;
private bucketName: string;
constructor(config: {
accessKeyId: string;
secretAccessKey: string;
region: string;
bucketName: string;
}) {
this.s3 = new S3Client({
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
});
this.bucketName = config.bucketName;
}
private async getObject(key: string): Promise<any | null> {
try {
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: key,
});
const response = await this.s3.send(command);
const body = await response.Body?.transformToString();
return body ? JSON.parse(body) : null;
} catch (error: any) {
if (error.name === "NoSuchKey") {
return null;
}
throw error;
}
}
async listCanvases(): Promise<CanvasMetadata[]> {
const command = new ListObjectsV2Command({
Bucket: this.bucketName,
Prefix: KEY_PREFIX_METADATA,
});
const response = await this.s3.send(command);
if (!response.Contents) {
return [];
}
const metadataPromises = response.Contents.map((obj: { Key?: string }) =>
this.getObject(obj.Key!),
);
const results = await Promise.all(metadataPromises);
return results.filter(
(m: CanvasMetadata | null): m is CanvasMetadata => m !== null,
);
}
async loadCanvas(id: string): Promise<CanvasData | null> {
const rawData = await this.getObject(`${KEY_PREFIX_DATA}${id}`);
return rawData ? hydrateCanvasData(rawData) : null;
}
async saveCanvas(id: string, data: CanvasData): Promise<void> {
const metadataKey = `${KEY_PREFIX_METADATA}${id}`;
const dataKey = `${KEY_PREFIX_DATA}${id}`;
const existingMetadata = await this.getObject(metadataKey);
if (!existingMetadata) {
throw new Error("Canvas metadata not found. Cannot save.");
}
const thumbnail = await generateThumbnail(
data.elements,
data.appState,
data.files,
);
const updatedMetadata: CanvasMetadata = {
...existingMetadata,
name: data.appState.name || existingMetadata.name,
updatedAt: new Date().toISOString(),
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
};
const dehydratedData = dehydrateCanvasData(data);
await Promise.all([
this.s3.send(
new PutObjectCommand({
Bucket: this.bucketName,
Key: metadataKey,
Body: JSON.stringify(updatedMetadata),
ContentType: "application/json",
}),
),
this.s3.send(
new PutObjectCommand({
Bucket: this.bucketName,
Key: dataKey,
Body: JSON.stringify(dehydratedData),
ContentType: "application/json",
}),
),
]);
}
async createCanvas(data: CanvasData): Promise<CanvasMetadata> {
const newId = window.crypto.randomUUID();
const now = new Date().toISOString();
const thumbnail = await generateThumbnail(
data.elements,
data.appState,
data.files,
);
const newMetadata: CanvasMetadata = {
id: newId,
name: data.appState.name || "Untitled Canvas",
createdAt: now,
updatedAt: now,
userId: 0,
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
};
const metadataKey = `${KEY_PREFIX_METADATA}${newId}`;
const dataKey = `${KEY_PREFIX_DATA}${newId}`;
const dehydratedData = dehydrateCanvasData(data);
await Promise.all([
this.s3.send(
new PutObjectCommand({
Bucket: this.bucketName,
Key: metadataKey,
Body: JSON.stringify(newMetadata),
ContentType: "application/json",
}),
),
this.s3.send(
new PutObjectCommand({
Bucket: this.bucketName,
Key: dataKey,
Body: JSON.stringify(dehydratedData),
ContentType: "application/json",
}),
),
]);
return newMetadata;
}
async deleteCanvas(id: string): Promise<void> {
const command = new DeleteObjectsCommand({
Bucket: this.bucketName,
Delete: {
Objects: [
{ Key: `${KEY_PREFIX_METADATA}${id}` },
{ Key: `${KEY_PREFIX_DATA}${id}` },
],
},
});
await this.s3.send(command);
}
async renameCanvas(id: string, newName: string): Promise<void> {
const metadataKey = `${KEY_PREFIX_METADATA}${id}`;
const metadata = await this.getObject(metadataKey);
if (!metadata) {
throw new Error("Canvas metadata not found. Cannot rename.");
}
metadata.name = newName;
metadata.updatedAt = new Date().toISOString();
const data = await this.loadCanvas(id);
if (!data) {
throw new Error("Canvas data not found. Cannot rename.");
}
data.appState.name = newName;
await this.saveCanvas(id, data);
}
}