Initial commit of version 0.2.0
This commit is contained in:
parent
86fc06b877
commit
dfb04462f3
108 changed files with 16167 additions and 80 deletions
17
app/src/lib/color.ts
Normal file
17
app/src/lib/color.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export function normalizeColor(value?: string | null): string {
|
||||
const normalized = (value ?? '').trim().replace(/^#/, '').toUpperCase();
|
||||
return /^[0-9A-F]{6}$/.test(normalized) ? normalized : 'D9DEE8';
|
||||
}
|
||||
|
||||
export function textColor(background?: string | null): string {
|
||||
const color = normalizeColor(background);
|
||||
const red = Number.parseInt(color.slice(0, 2), 16);
|
||||
const green = Number.parseInt(color.slice(2, 4), 16);
|
||||
const blue = Number.parseInt(color.slice(4, 6), 16);
|
||||
const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255;
|
||||
return luminance > 0.58 ? '#11151d' : '#ffffff';
|
||||
}
|
||||
|
||||
export function cssColor(value?: string | null): string {
|
||||
return `#${normalizeColor(value)}`;
|
||||
}
|
||||
16
app/src/lib/credentials.ts
Normal file
16
app/src/lib/credentials.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export async function loadPassword(server: string, username: string): Promise<string> {
|
||||
if (!server || !username || !('__TAURI_INTERNALS__' in window)) return '';
|
||||
return (await invoke<string | null>('get_credential', { server, username })) ?? '';
|
||||
}
|
||||
|
||||
export async function savePassword(server: string, username: string, password: string): Promise<void> {
|
||||
if (!('__TAURI_INTERNALS__' in window)) return;
|
||||
await invoke('set_credential', { server, username, password });
|
||||
}
|
||||
|
||||
export async function removePassword(server: string, username: string): Promise<void> {
|
||||
if (!('__TAURI_INTERNALS__' in window)) return;
|
||||
await invoke('delete_credential', { server, username });
|
||||
}
|
||||
99
app/src/lib/deck-api.test.ts
Normal file
99
app/src/lib/deck-api.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { cardUpdateFrom, DeckApiClient, type FetchLike } from './deck-api';
|
||||
import type { Card } from './types';
|
||||
|
||||
const options = { serverUrl: 'https://cloud.example.test', username: 'admin', appPassword: 'secret' };
|
||||
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
describe('DeckApiClient', () => {
|
||||
it('sends required authentication headers', async () => {
|
||||
const fetchImpl = vi.fn<FetchLike>().mockImplementation(async () => json([]));
|
||||
const client = await DeckApiClient.connect(options, fetchImpl);
|
||||
await client.getBoards();
|
||||
const headers = new Headers(fetchImpl.mock.calls[1][1]?.headers);
|
||||
expect(headers.get('OCS-APIRequest')).toBe('true');
|
||||
expect(headers.get('Authorization')).toMatch(/^Basic /);
|
||||
});
|
||||
|
||||
it('falls back to the rewrite-friendly API path', async () => {
|
||||
const fetchImpl = vi.fn<FetchLike>().mockImplementation(async (input) =>
|
||||
String(input).includes('/index.php/') ? json({ message: 'not found' }, 404) : json([])
|
||||
);
|
||||
const client = await DeckApiClient.connect(options, fetchImpl);
|
||||
expect(client.apiBaseUrl.toString()).toBe('https://cloud.example.test/apps/deck/api/v1.0/');
|
||||
});
|
||||
|
||||
it('preserves complete card fields and normalizes object owners', () => {
|
||||
const card: Card = {
|
||||
id: 1,
|
||||
stackId: 2,
|
||||
title: 'Task',
|
||||
description: 'Details',
|
||||
type: 'plain',
|
||||
owner: { uid: 'admin' },
|
||||
order: 3,
|
||||
done: null,
|
||||
color: '#aabbcc'
|
||||
};
|
||||
expect(cardUpdateFrom(card, { done: '2026-01-01T00:00:00Z' })).toMatchObject({
|
||||
owner: 'admin',
|
||||
description: 'Details',
|
||||
order: 3,
|
||||
done: '2026-01-01T00:00:00Z',
|
||||
color: 'AABBCC'
|
||||
});
|
||||
});
|
||||
|
||||
it('can explicitly clear description, due date, and completion', () => {
|
||||
const card: Card = {
|
||||
id: 1, stackId: 2, title: 'Task', description: 'Old', type: 'plain', owner: 'admin', order: 0,
|
||||
duedate: '2026-01-01T00:00:00Z', done: '2026-01-01T00:00:00Z', color: 'ABCDEF'
|
||||
};
|
||||
expect(cardUpdateFrom(card, { description: null, duedate: null, done: null })).toMatchObject({
|
||||
description: null, duedate: null, done: null, color: 'ABCDEF'
|
||||
});
|
||||
});
|
||||
|
||||
it('retries cross-stack movement through the destination route when Deck ignores the body stack', async () => {
|
||||
let puts = 0;
|
||||
const fetchImpl = vi.fn<FetchLike>().mockImplementation(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/boards')) return json([]);
|
||||
if (init?.method === 'PUT') {
|
||||
puts += 1;
|
||||
return json(true);
|
||||
}
|
||||
if (url.includes('/stacks/8/')) {
|
||||
return puts > 1
|
||||
? json({ id: 11, stackId: 8, title: 'Moved', owner: 'admin' })
|
||||
: json({ status: 404 }, 404);
|
||||
}
|
||||
return json({ id: 11, stackId: 7, title: 'Not moved', owner: 'admin' });
|
||||
});
|
||||
const client = await DeckApiClient.connect(options, fetchImpl);
|
||||
|
||||
const moved = await client.reorderCard(2, 7, 11, 8, 0);
|
||||
|
||||
expect(moved.stackId).toBe(8);
|
||||
expect(puts).toBe(2);
|
||||
});
|
||||
|
||||
it('reorders a stack through the same OCS endpoint as the Nextcloud web client and verifies persistence', async () => {
|
||||
const fetchImpl = vi.fn<FetchLike>().mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/boards')) return json([]);
|
||||
if (url.includes('/ocs/v2.php/')) return json({ ocs: { meta: { status: 'ok' }, data: [] } });
|
||||
return json([
|
||||
{ id: 8, boardId: 2, title: 'Earlier', order: 0, cards: [] },
|
||||
{ id: 7, boardId: 2, title: 'Today', order: 1, cards: [] }
|
||||
]);
|
||||
});
|
||||
const client = await DeckApiClient.connect(options, fetchImpl);
|
||||
await client.reorderStack(2, 7, 1);
|
||||
const [url, init] = fetchImpl.mock.calls[1];
|
||||
expect(String(url)).toBe('https://cloud.example.test/ocs/v2.php/apps/deck/api/v1.0/stacks/7/reorder?format=json');
|
||||
expect(init?.method).toBe('PUT');
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ order: 1, boardId: 2 });
|
||||
expect(String(fetchImpl.mock.calls[2][0])).toContain('/boards/2/stacks');
|
||||
});
|
||||
});
|
||||
212
app/src/lib/deck-api.ts
Normal file
212
app/src/lib/deck-api.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
||||
import { normalizeColor } from './color';
|
||||
import type { Board, Card, CardDraft, DeckUser, Stack } from './types';
|
||||
|
||||
export const DOCUMENTED_API_PATH = 'index.php/apps/deck/api/v1.0/';
|
||||
export const REWRITTEN_API_PATH = 'apps/deck/api/v1.0/';
|
||||
|
||||
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export class DeckApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly url: string,
|
||||
public readonly responseBody: string
|
||||
) {
|
||||
super(`Deck request failed with ${status}${responseBody ? `: ${responseBody}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConnectionOptions {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
appPassword: string;
|
||||
apiPath?: string;
|
||||
}
|
||||
|
||||
export interface CardUpdate {
|
||||
title: string;
|
||||
type: string;
|
||||
owner: string;
|
||||
description: string | null;
|
||||
order: number;
|
||||
duedate: string | null;
|
||||
startdate: string | null;
|
||||
archived: boolean;
|
||||
done: string | null;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function encodeBasicAuth(username: string, password: string): string {
|
||||
const bytes = new TextEncoder().encode(`${username}:${password}`);
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function normalizeServerUrl(value: string): URL {
|
||||
const url = new URL(value.trim());
|
||||
const isLocal = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
||||
if (url.protocol !== 'https:' && !(isLocal && url.protocol === 'http:')) {
|
||||
throw new Error('Decky requires HTTPS except when connecting to localhost.');
|
||||
}
|
||||
url.pathname = `${url.pathname.replace(/\/+$/, '')}/`;
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url;
|
||||
}
|
||||
|
||||
function ownerId(owner: string | DeckUser): string {
|
||||
if (typeof owner === 'string') return owner;
|
||||
return owner.uid || owner.primaryKey || '';
|
||||
}
|
||||
|
||||
export function cardUpdateFrom(card: Card, changes: Partial<CardUpdate> = {}): CardUpdate {
|
||||
return {
|
||||
title: changes.title ?? card.title,
|
||||
type: changes.type ?? card.type ?? 'plain',
|
||||
owner: changes.owner ?? ownerId(card.owner),
|
||||
description: changes.description !== undefined ? changes.description : card.description ?? null,
|
||||
order: changes.order ?? card.order,
|
||||
duedate: changes.duedate !== undefined ? changes.duedate : card.duedate ?? null,
|
||||
startdate: changes.startdate !== undefined ? changes.startdate : card.startdate ?? null,
|
||||
archived: changes.archived ?? card.archived ?? false,
|
||||
done: changes.done !== undefined ? changes.done : card.done ?? null,
|
||||
color: normalizeColor(changes.color ?? card.color)
|
||||
};
|
||||
}
|
||||
|
||||
export class DeckApiClient {
|
||||
private constructor(
|
||||
private readonly options: ConnectionOptions,
|
||||
public readonly apiBaseUrl: URL,
|
||||
private readonly fetchImpl: FetchLike
|
||||
) {}
|
||||
|
||||
static async connect(options: ConnectionOptions, fetchImpl: FetchLike = tauriFetch): Promise<DeckApiClient> {
|
||||
if (!options.username.trim() || !options.appPassword) throw new Error('Username and app password are required.');
|
||||
const server = normalizeServerUrl(options.serverUrl);
|
||||
const paths = [...new Set([options.apiPath, DOCUMENTED_API_PATH, REWRITTEN_API_PATH].filter(Boolean))] as string[];
|
||||
let lastError: unknown;
|
||||
|
||||
for (const path of paths) {
|
||||
const client = new DeckApiClient(options, new URL(path.replace(/^\/+/, ''), server), fetchImpl);
|
||||
try {
|
||||
await client.getBoards();
|
||||
return client;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!(error instanceof DeckApiError) || ![404, 405].includes(error.status)) throw error;
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error('The Nextcloud Deck API could not be reached.');
|
||||
}
|
||||
|
||||
getBoards(): Promise<Board[]> {
|
||||
return this.request<Board[]>('boards');
|
||||
}
|
||||
|
||||
getStacks(boardId: number): Promise<Stack[]> {
|
||||
return this.request<Stack[]>(`boards/${boardId}/stacks`);
|
||||
}
|
||||
|
||||
updateStack(boardId: number, stackId: number, title: string, order: number): Promise<Stack> {
|
||||
return this.request<Stack>(`boards/${boardId}/stacks/${stackId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ title, order })
|
||||
});
|
||||
}
|
||||
|
||||
async reorderStack(boardId: number, stackId: number, order: number): Promise<Stack[]> {
|
||||
const server = normalizeServerUrl(this.options.serverUrl);
|
||||
const url = new URL(`ocs/v2.php/apps/deck/api/v1.0/stacks/${stackId}/reorder`, server);
|
||||
url.searchParams.set('format', 'json');
|
||||
await this.requestUrl<unknown>(url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ order, boardId })
|
||||
});
|
||||
|
||||
const stacks = (await this.getStacks(boardId))
|
||||
.filter((stack) => !stack.deletedAt)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
const actualIndex = stacks.findIndex((stack) => stack.id === stackId);
|
||||
if (actualIndex !== order) {
|
||||
throw new Error(`Nextcloud did not persist the stack reorder (expected position ${order + 1}, received ${actualIndex + 1}).`);
|
||||
}
|
||||
return stacks;
|
||||
}
|
||||
|
||||
getCard(boardId: number, stackId: number, cardId: number): Promise<Card> {
|
||||
return this.request<Card>(`boards/${boardId}/stacks/${stackId}/cards/${cardId}`);
|
||||
}
|
||||
|
||||
createCard(boardId: number, stackId: number, draft: CardDraft): Promise<Card> {
|
||||
return this.request<Card>(`boards/${boardId}/stacks/${stackId}/cards`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: draft.title,
|
||||
type: 'plain',
|
||||
order: 999,
|
||||
description: draft.description || null,
|
||||
duedate: draft.dueDate ? new Date(draft.dueDate).toISOString() : null,
|
||||
color: normalizeColor(draft.color)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
updateCard(boardId: number, stackId: number, cardId: number, update: CardUpdate): Promise<Card> {
|
||||
return this.request<Card>(`boards/${boardId}/stacks/${stackId}/cards/${cardId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(update)
|
||||
});
|
||||
}
|
||||
|
||||
deleteCard(boardId: number, stackId: number, cardId: number): Promise<void> {
|
||||
return this.request<void>(`boards/${boardId}/stacks/${stackId}/cards/${cardId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async reorderCard(boardId: number, sourceStackId: number, cardId: number, stackId: number, order: number): Promise<Card> {
|
||||
const body = JSON.stringify({ stackId, order });
|
||||
await this.request<unknown>(`boards/${boardId}/stacks/${sourceStackId}/cards/${cardId}/reorder`, { method: 'PUT', body });
|
||||
let card = await this.resolveMovedCard(boardId, sourceStackId, stackId, cardId);
|
||||
if (sourceStackId !== stackId && card.stackId === sourceStackId) {
|
||||
await this.request<unknown>(`boards/${boardId}/stacks/${stackId}/cards/${cardId}/reorder`, { method: 'PUT', body });
|
||||
card = await this.resolveMovedCard(boardId, sourceStackId, stackId, cardId);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
private async resolveMovedCard(boardId: number, sourceStackId: number, targetStackId: number, cardId: number): Promise<Card> {
|
||||
try {
|
||||
return await this.getCard(boardId, targetStackId, cardId);
|
||||
} catch (error) {
|
||||
if (error instanceof DeckApiError && error.status === 404 && sourceStackId !== targetStackId) {
|
||||
return this.getCard(boardId, sourceStackId, cardId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(relativePath: string, init: RequestInit = {}): Promise<T> {
|
||||
const url = new URL(relativePath, this.apiBaseUrl).toString();
|
||||
return this.requestUrl<T>(url, init);
|
||||
}
|
||||
|
||||
private async requestUrl<T>(url: string | URL, init: RequestInit = {}): Promise<T> {
|
||||
const urlString = url.toString();
|
||||
const response = await this.fetchImpl(urlString, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'OCS-APIRequest': 'true',
|
||||
Authorization: `Basic ${encodeBasicAuth(this.options.username, this.options.appPassword)}`,
|
||||
...init.headers
|
||||
}
|
||||
});
|
||||
if (!response.ok) throw new DeckApiError(response.status, urlString, await response.text());
|
||||
if (response.status === 204) return undefined as T;
|
||||
const text = await response.text();
|
||||
return (text ? JSON.parse(text) : undefined) as T;
|
||||
}
|
||||
}
|
||||
57
app/src/lib/reorder.test.ts
Normal file
57
app/src/lib/reorder.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { moveCard, moveStack } from './reorder';
|
||||
import type { Stack } from './types';
|
||||
|
||||
const stacks: Stack[] = [
|
||||
{ id: 1, boardId: 9, title: 'One', order: 0, cards: [
|
||||
{ id: 10, stackId: 1, title: 'A', type: 'plain', owner: 'admin', order: 0 },
|
||||
{ id: 11, stackId: 1, title: 'B', type: 'plain', owner: 'admin', order: 1 }
|
||||
] },
|
||||
{ id: 2, boardId: 9, title: 'Two', order: 1, cards: [
|
||||
{ id: 20, stackId: 2, title: 'C', type: 'plain', owner: 'admin', order: 0 }
|
||||
] },
|
||||
{ id: 3, boardId: 9, title: 'Three', order: 2, cards: [] }
|
||||
];
|
||||
|
||||
describe('reorder helpers', () => {
|
||||
it('moves a card between stacks and assigns contiguous order values', () => {
|
||||
const result = moveCard(stacks, 10, 1, 2, 1)!;
|
||||
expect(result.targetIndex).toBe(1);
|
||||
expect(result.stacks[0].cards.map((card) => [card.title, card.order])).toEqual([['B', 0]]);
|
||||
expect(result.stacks[1].cards.map((card) => [card.title, card.stackId, card.order])).toEqual([
|
||||
['C', 2, 0], ['A', 2, 1]
|
||||
]);
|
||||
});
|
||||
|
||||
it('accounts for the removed item when reordering inside one stack', () => {
|
||||
const result = moveCard(stacks, 10, 1, 1, 2)!;
|
||||
expect(result.targetIndex).toBe(1);
|
||||
expect(result.stacks[0].cards.map((card) => card.title)).toEqual(['B', 'A']);
|
||||
});
|
||||
|
||||
it('moves stacks before or after the selected target', () => {
|
||||
const result = moveStack(stacks, 1, 2, true)!;
|
||||
expect(result.targetIndex).toBe(1);
|
||||
expect(result.stacks.map((stack) => [stack.title, stack.order])).toEqual([
|
||||
['Two', 0], ['One', 1], ['Three', 2]
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports consecutive card previews while a drag crosses multiple positions', () => {
|
||||
const firstPreview = moveCard(stacks, 10, 1, 2, 1)!;
|
||||
const secondPreview = moveCard(firstPreview.stacks, 10, 2, 2, 0)!;
|
||||
|
||||
expect(secondPreview.stacks[1].cards.map((card) => [card.title, card.order])).toEqual([
|
||||
['A', 0], ['C', 1]
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports consecutive stack previews while a drag crosses multiple stacks', () => {
|
||||
const firstPreview = moveStack(stacks, 1, 2, true)!;
|
||||
const secondPreview = moveStack(firstPreview.stacks, 1, 3, true)!;
|
||||
|
||||
expect(secondPreview.stacks.map((stack) => [stack.title, stack.order])).toEqual([
|
||||
['Two', 0], ['Three', 1], ['One', 2]
|
||||
]);
|
||||
});
|
||||
});
|
||||
44
app/src/lib/reorder.ts
Normal file
44
app/src/lib/reorder.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { Stack } from './types';
|
||||
|
||||
export function moveCard(
|
||||
stacks: Stack[],
|
||||
cardId: number,
|
||||
sourceStackId: number,
|
||||
targetStackId: number,
|
||||
requestedIndex: number
|
||||
): { stacks: Stack[]; targetIndex: number } | null {
|
||||
const source = stacks.find((stack) => stack.id === sourceStackId);
|
||||
const sourceIndex = source?.cards.findIndex((card) => card.id === cardId) ?? -1;
|
||||
const card = source?.cards[sourceIndex];
|
||||
if (!source || !card || sourceIndex < 0) return null;
|
||||
|
||||
const next = stacks.map((stack) => ({ ...stack, cards: [...(stack.cards ?? [])] }));
|
||||
const nextSource = next.find((stack) => stack.id === sourceStackId)!;
|
||||
nextSource.cards.splice(sourceIndex, 1);
|
||||
const nextTarget = next.find((stack) => stack.id === targetStackId);
|
||||
if (!nextTarget) return null;
|
||||
|
||||
let targetIndex = requestedIndex;
|
||||
if (sourceStackId === targetStackId && sourceIndex < targetIndex) targetIndex -= 1;
|
||||
targetIndex = Math.max(0, Math.min(targetIndex, nextTarget.cards.length));
|
||||
nextTarget.cards.splice(targetIndex, 0, { ...card, stackId: targetStackId });
|
||||
next.forEach((stack) => stack.cards.forEach((item, order) => { item.order = order; }));
|
||||
return { stacks: next, targetIndex };
|
||||
}
|
||||
|
||||
export function moveStack(
|
||||
stacks: Stack[],
|
||||
stackId: number,
|
||||
targetStackId: number,
|
||||
after: boolean
|
||||
): { stacks: Stack[]; targetIndex: number } | null {
|
||||
const moved = stacks.find((stack) => stack.id === stackId);
|
||||
if (!moved || !stacks.some((stack) => stack.id === targetStackId) || stackId === targetStackId) return null;
|
||||
const next = stacks.filter((stack) => stack.id !== stackId).map((stack) => ({ ...stack }));
|
||||
let targetIndex = next.findIndex((stack) => stack.id === targetStackId);
|
||||
if (after) targetIndex += 1;
|
||||
targetIndex = Math.max(0, targetIndex);
|
||||
next.splice(targetIndex, 0, { ...moved });
|
||||
next.forEach((stack, order) => { stack.order = order; });
|
||||
return { stacks: next, targetIndex };
|
||||
}
|
||||
31
app/src/lib/settings.ts
Normal file
31
app/src/lib/settings.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { AppSettings } from './types';
|
||||
|
||||
const key = 'decky.settings.v2';
|
||||
|
||||
export const defaultSettings: AppSettings = {
|
||||
serverUrl: '',
|
||||
username: '',
|
||||
sidebarCollapsed: false,
|
||||
boardViews: {},
|
||||
activeColor: 'E69A88',
|
||||
completedColor: '80C3A5'
|
||||
};
|
||||
|
||||
export function loadSettings(): AppSettings {
|
||||
try {
|
||||
const value = localStorage.getItem(key);
|
||||
if (!value) return structuredClone(defaultSettings);
|
||||
const parsed = JSON.parse(value) as Partial<AppSettings>;
|
||||
return {
|
||||
...structuredClone(defaultSettings),
|
||||
...parsed,
|
||||
boardViews: parsed.boardViews ?? {}
|
||||
};
|
||||
} catch {
|
||||
return structuredClone(defaultSettings);
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings(settings: AppSettings): void {
|
||||
localStorage.setItem(key, JSON.stringify(settings));
|
||||
}
|
||||
63
app/src/lib/types.ts
Normal file
63
app/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
export type BoardOrientation = 'horizontal' | 'vertical';
|
||||
|
||||
export interface DeckUser {
|
||||
primaryKey?: string;
|
||||
uid?: string;
|
||||
displayname?: string;
|
||||
}
|
||||
|
||||
export interface Board {
|
||||
id: number;
|
||||
title: string;
|
||||
owner?: DeckUser;
|
||||
color?: string | null;
|
||||
archived?: boolean;
|
||||
lastModified?: number;
|
||||
deletedAt?: number;
|
||||
}
|
||||
|
||||
export interface Stack {
|
||||
id: number;
|
||||
boardId: number;
|
||||
title: string;
|
||||
order: number;
|
||||
deletedAt?: number;
|
||||
cards: Card[];
|
||||
}
|
||||
|
||||
export interface Card {
|
||||
id: number;
|
||||
stackId: number;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
type: string;
|
||||
owner: string | DeckUser;
|
||||
order: number;
|
||||
archived?: boolean;
|
||||
done?: string | null;
|
||||
duedate?: string | null;
|
||||
startdate?: string | null;
|
||||
color?: string | null;
|
||||
lastModified?: number;
|
||||
}
|
||||
|
||||
export interface CardDraft {
|
||||
title: string;
|
||||
description: string;
|
||||
dueDate: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface BoardViewSettings {
|
||||
orientation: BoardOrientation;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
selectedBoardId?: number;
|
||||
sidebarCollapsed: boolean;
|
||||
boardViews: Record<number, BoardViewSettings>;
|
||||
activeColor: string;
|
||||
completedColor: string;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue