Files
drift/src/lib/workspace.ts
T
2026-02-28 02:41:17 +01:00

272 lines
6.3 KiB
TypeScript

import { Workspace, TodoEntity, DocEntity, Status, LayoutNode } from "./types";
export function createDefaultWorkspace(): Workspace {
const now = Date.now();
const todoId = crypto.randomUUID();
const docId = crypto.randomUUID();
const defaultStatuses: Status[] = [
{ id: "status_todo", name: "To Do", order: 0, color: "#808080" },
{
id: "status_inprogress",
name: "In Progress",
order: 1,
color: "#0ea5e9",
},
{ id: "status_done", name: "Done", order: 2, color: "#10b981" },
];
const todoEntity: TodoEntity = {
id: todoId,
type: "todo",
name: "Tasks",
config: { statuses: defaultStatuses },
tags: {},
tasks: {},
createdAt: now,
updatedAt: now,
};
const docEntity: DocEntity = {
id: docId,
type: "doc",
name: "Notes",
content: { version: "2.28.0", time: now, blocks: [] },
createdAt: now,
updatedAt: now,
};
return {
id: crypto.randomUUID(),
name: "My Workspace",
entities: {
[todoId]: todoEntity,
[docId]: docEntity,
},
layout: [
{
type: "group",
id: crypto.randomUUID(),
name: "Productivity",
children: [
{ entityId: todoId, type: "item" },
{ entityId: docId, type: "item" },
],
},
],
createdAt: now,
updatedAt: now,
};
}
export function loadWorkspaceFromLocalStorage(id: string): Workspace {
try {
const raw = localStorage.getItem(id);
if (!raw) {
return createDefaultWorkspace();
}
const parsed = JSON.parse(raw);
// Basic shape validation
if (
typeof parsed !== "object" ||
parsed === null ||
typeof parsed.id !== "string"
) {
console.warn("Invalid workspace shape. Resetting.");
return createDefaultWorkspace();
}
return parsed as Workspace;
} catch (err) {
console.error("Failed to load workspace:", err);
return createDefaultWorkspace();
}
}
export function saveWorkspaceToLocalStorage(workspace: Workspace): void {
try {
const serialized = JSON.stringify(workspace);
// keep using raw id for backwards compatibility
localStorage.setItem(workspace.id, serialized);
} catch (err) {
console.error("Failed to save workspace:", err);
}
}
/**
* Scan localStorage and return all entries that appear to be workspaces.
* We ignore keys starting with "route_" which are used for routing state.
*/
export function listWorkspaces(): Workspace[] {
const workspaces: Workspace[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key) continue;
if (key.startsWith("route_")) continue;
const raw = localStorage.getItem(key);
if (!raw) continue;
try {
const parsed = JSON.parse(raw);
if (
parsed &&
typeof parsed === "object" &&
typeof parsed.id === "string" &&
typeof parsed.name === "string" &&
parsed.entities &&
parsed.layout
) {
workspaces.push(parsed as Workspace);
}
} catch {
// ignore non-json values
}
}
return workspaces;
}
// layout helpers -------------------------------------------------------------------
type RemoveResult = { layout: LayoutNode[]; removedEntityIds: string[] };
function collectEntityIds(node: LayoutNode): string[] {
if (node.type === "item") return [node.entityId];
const ids: string[] = [];
for (const child of node.children) {
ids.push(...collectEntityIds(child));
}
return ids;
}
export function removeLayoutNode(
layout: LayoutNode[],
id: string,
): RemoveResult {
let removed: string[] = [];
function recurse(nodes: LayoutNode[]): LayoutNode[] {
return nodes
.map((node) => {
if (node.type === "group") {
if (node.id === id) {
removed.push(...collectEntityIds(node));
return null;
}
const children = recurse(node.children);
if (children.length !== node.children.length) {
// some children removed
}
return { ...node, children } as LayoutNode;
} else {
if (node.entityId === id) {
removed.push(node.entityId);
return null;
}
return node;
}
})
.filter(Boolean) as LayoutNode[];
}
const newLayout = recurse(layout);
return { layout: newLayout, removedEntityIds: removed };
}
export function renameLayoutGroup(
layout: LayoutNode[],
id: string,
newName: string,
): LayoutNode[] {
return layout.map((node) => {
if (node.type === "group") {
if (node.id === id) {
return { ...node, name: newName };
}
return {
...node,
children: renameLayoutGroup(node.children, id, newName),
};
}
return node;
});
}
export function addLayoutGroup(
layout: LayoutNode[],
parentId: string | null,
group: LayoutNode,
): LayoutNode[] {
if (!parentId) {
return [...layout, group];
}
return layout.map((node) => {
if (node.type === "group") {
if (node.id === parentId) {
return { ...node, children: [...node.children, group] };
}
return {
...node,
children: addLayoutGroup(node.children, parentId, group),
};
}
return node;
});
}
export function addLayoutItem(
layout: LayoutNode[],
parentId: string | null,
item: LayoutNode,
): LayoutNode[] {
if (!parentId) {
return [...layout, item];
}
return layout.map((node) => {
if (node.type === "group") {
if (node.id === parentId) {
return { ...node, children: [...node.children, item] };
}
return {
...node,
children: addLayoutItem(node.children, parentId, item),
};
}
return node;
});
}
// entity creation helpers ----------------------------------------------------------
export function createTodoEntity(name = "New list"): TodoEntity {
const id = crypto.randomUUID();
const now = Date.now();
return {
id,
type: "todo",
name,
config: { statuses: [] },
tags: {},
tasks: {},
createdAt: now,
updatedAt: now,
};
}
export function createDocEntity(name = "New doc"): DocEntity {
const id = crypto.randomUUID();
const now = Date.now();
return {
id,
type: "doc",
name,
content: { version: "2.28.0", time: now, blocks: [] },
createdAt: now,
updatedAt: now,
};
}