feat(mvp): phase 5 - board, section & widget system

Add board/section/widget CRUD APIs with permission filtering, board view
page with collapsible sections and app widgets in responsive grid, form-based
board editor, and 9 Svelte components (Board, Section, Widget families).
This commit is contained in:
2026-03-24 21:05:00 +03:00
parent 4d941f566f
commit b0d77d3c29
23 changed files with 1564 additions and 27 deletions
+98
View File
@@ -0,0 +1,98 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import * as boardService from '$lib/server/services/boardService.js';
import * as permissionService from '$lib/server/services/permissionService.js';
import { createBoardSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
import { prisma } from '$lib/server/prisma.js';
/**
* GET /api/boards — List boards filtered by permissions.
* - Admin: sees all boards
* - Regular user: sees boards where they have VIEW+ permission
* - Guest (no user): sees only guest-accessible boards
*/
export const GET: RequestHandler = async (event) => {
const user = event.locals.user;
try {
if (!user) {
// Guest: only guest-accessible boards
const boards = await prisma.board.findMany({
where: { isGuestAccessible: true },
orderBy: { createdAt: 'asc' },
include: { _count: { select: { sections: true } } }
});
return json(success(boards));
}
if (user.role === UserRole.ADMIN) {
// Admin: all boards
const boards = await boardService.findAllBoards();
return json(success(boards));
}
// Regular user: boards with VIEW+ permission (user-level or group-level)
const allBoards = await boardService.findAllBoards();
const accessibleBoards = [];
for (const board of allBoards) {
// Guest-accessible boards are visible to all authenticated users too
if (board.isGuestAccessible) {
accessibleBoards.push(board);
continue;
}
const result = await permissionService.checkPermission(
EntityType.BOARD,
board.id,
user.id,
PermissionLevel.VIEW
);
if (result.hasPermission) {
accessibleBoards.push(board);
}
}
return json(success(accessibleBoards));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch boards';
return json(error(message), { status: 500 });
}
};
/**
* POST /api/boards — Create a new board (auth required).
*/
export const POST: RequestHandler = async (event) => {
const user = event.locals.user;
if (!user) {
return json(error('Authentication required'), { status: 401 });
}
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = createBoardSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const board = await boardService.createBoard({
...parsed.data,
createdById: user.id
});
return json(success(board), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create board';
return json(error(message), { status: 500 });
}
};
+127
View File
@@ -0,0 +1,127 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import * as boardService from '$lib/server/services/boardService.js';
import * as permissionService from '$lib/server/services/permissionService.js';
import { updateBoardSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js';
/**
* GET /api/boards/:id — Get a single board with sections and widgets.
*/
export const GET: RequestHandler = async (event) => {
const { id } = event.params;
const user = event.locals.user;
try {
// Check access: guest can only see guest-accessible boards
if (!user) {
const isGuest = await isBoardGuestAccessible(id);
if (!isGuest) {
return json(error('Authentication required'), { status: 401 });
}
} else if (user.role !== UserRole.ADMIN) {
const result = await permissionService.checkPermission(
EntityType.BOARD,
id,
user.id,
PermissionLevel.VIEW
);
if (!result.hasPermission) {
const isGuest = await isBoardGuestAccessible(id);
if (!isGuest) {
return json(error('Insufficient permissions'), { status: 403 });
}
}
}
const board = await boardService.findBoardById(id);
return json(success(board));
} catch (err) {
const message = err instanceof Error ? err.message : 'Board not found';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
/**
* PATCH /api/boards/:id — Update a board (auth required).
*/
export const PATCH: RequestHandler = async (event) => {
const user = event.locals.user;
if (!user) {
return json(error('Authentication required'), { status: 401 });
}
const { id } = event.params;
// Check edit permission
if (user.role !== UserRole.ADMIN) {
const result = await permissionService.checkPermission(
EntityType.BOARD,
id,
user.id,
PermissionLevel.EDIT
);
if (!result.hasPermission) {
return json(error('Insufficient permissions'), { status: 403 });
}
}
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = updateBoardSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const board = await boardService.updateBoard(id, parsed.data);
return json(success(board));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update board';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
/**
* DELETE /api/boards/:id — Delete a board (auth required).
*/
export const DELETE: RequestHandler = async (event) => {
const user = event.locals.user;
if (!user) {
return json(error('Authentication required'), { status: 401 });
}
const { id } = event.params;
// Only admin or users with ADMIN permission on the board can delete
if (user.role !== UserRole.ADMIN) {
const result = await permissionService.checkPermission(
EntityType.BOARD,
id,
user.id,
PermissionLevel.ADMIN
);
if (!result.hasPermission) {
return json(error('Insufficient permissions'), { status: 403 });
}
}
try {
await boardService.removeBoard(id);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete board';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
@@ -0,0 +1,71 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import * as boardService from '$lib/server/services/boardService.js';
import { createSectionSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
import { prisma } from '$lib/server/prisma.js';
/**
* GET /api/boards/:id/sections — List sections for a board.
*/
export const GET: RequestHandler = async (event) => {
const { id } = event.params;
try {
// Verify board exists
await boardService.findBoardById(id);
const sections = await prisma.section.findMany({
where: { boardId: id },
orderBy: { order: 'asc' },
include: {
widgets: {
orderBy: { order: 'asc' }
}
}
});
return json(success(sections));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch sections';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
/**
* POST /api/boards/:id/sections — Create a section in a board (auth required).
*/
export const POST: RequestHandler = async (event) => {
const user = event.locals.user;
if (!user) {
return json(error('Authentication required'), { status: 401 });
}
const { id } = event.params;
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
// Inject the boardId from the URL param
const parsed = createSectionSchema.safeParse({ ...body as object, boardId: id });
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
// Verify board exists
await boardService.findBoardById(id);
const section = await boardService.createSection(parsed.data);
return json(success(section), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create section';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
@@ -0,0 +1,76 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import * as boardService from '$lib/server/services/boardService.js';
import { updateSectionSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/boards/:id/sections/:sid — Get a single section.
*/
export const GET: RequestHandler = async (event) => {
const { sid } = event.params;
try {
const section = await boardService.findSectionById(sid);
return json(success(section));
} catch (err) {
const message = err instanceof Error ? err.message : 'Section not found';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
/**
* PATCH /api/boards/:id/sections/:sid — Update a section (auth required).
*/
export const PATCH: RequestHandler = async (event) => {
const user = event.locals.user;
if (!user) {
return json(error('Authentication required'), { status: 401 });
}
const { sid } = event.params;
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = updateSectionSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const section = await boardService.updateSection(sid, parsed.data);
return json(success(section));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update section';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
/**
* DELETE /api/boards/:id/sections/:sid — Delete a section (auth required).
*/
export const DELETE: RequestHandler = async (event) => {
const user = event.locals.user;
if (!user) {
return json(error('Authentication required'), { status: 401 });
}
const { sid } = event.params;
try {
await boardService.removeSection(sid);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete section';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
@@ -0,0 +1,137 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import * as boardService from '$lib/server/services/boardService.js';
import { createWidgetSchema, updateWidgetSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
import { prisma } from '$lib/server/prisma.js';
/**
* GET /api/boards/:id/sections/:sid/widgets — List widgets in a section.
*/
export const GET: RequestHandler = async (event) => {
const { sid } = event.params;
try {
// Verify section exists
await boardService.findSectionById(sid);
const widgets = await prisma.widget.findMany({
where: { sectionId: sid },
orderBy: { order: 'asc' },
include: {
app: {
include: {
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
}
}
}
}
});
return json(success(widgets));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch widgets';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
/**
* POST /api/boards/:id/sections/:sid/widgets — Create a widget (auth required).
*/
export const POST: RequestHandler = async (event) => {
const user = event.locals.user;
if (!user) {
return json(error('Authentication required'), { status: 401 });
}
const { sid } = event.params;
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
// Inject sectionId from URL param
const parsed = createWidgetSchema.safeParse({ ...body as object, sectionId: sid });
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
// Verify section exists
await boardService.findSectionById(sid);
const widget = await boardService.createWidget(parsed.data);
return json(success(widget), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create widget';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
/**
* PATCH /api/boards/:id/sections/:sid/widgets — Update a widget by widgetId in body (auth required).
*/
export const PATCH: RequestHandler = async (event) => {
const user = event.locals.user;
if (!user) {
return json(error('Authentication required'), { status: 401 });
}
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const { widgetId, ...updateData } = body as { widgetId?: string; [key: string]: unknown };
if (!widgetId) {
return json(error('widgetId is required'), { status: 400 });
}
const parsed = updateWidgetSchema.safeParse(updateData);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const widget = await boardService.updateWidget(widgetId, parsed.data);
return json(success(widget));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update widget';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
/**
* DELETE /api/boards/:id/sections/:sid/widgets — Delete a widget by widgetId in query (auth required).
*/
export const DELETE: RequestHandler = async (event) => {
const user = event.locals.user;
if (!user) {
return json(error('Authentication required'), { status: 401 });
}
const widgetId = event.url.searchParams.get('widgetId');
if (!widgetId) {
return json(error('widgetId query parameter is required'), { status: 400 });
}
try {
await boardService.removeWidget(widgetId);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete widget';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
+48
View File
@@ -0,0 +1,48 @@
import type { PageServerLoad } from './$types.js';
import * as boardService from '$lib/server/services/boardService.js';
import * as permissionService from '$lib/server/services/permissionService.js';
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
import { prisma } from '$lib/server/prisma.js';
export const load: PageServerLoad = async ({ locals }) => {
const user = locals.user;
if (!user) {
// Guest: only guest-accessible boards
const boards = await prisma.board.findMany({
where: { isGuestAccessible: true },
orderBy: { createdAt: 'asc' },
include: { _count: { select: { sections: true } } }
});
return { boards, isGuest: true };
}
if (user.role === UserRole.ADMIN) {
const boards = await boardService.findAllBoards();
return { boards, isGuest: false };
}
// Regular user: filter by permissions
const allBoards = await boardService.findAllBoards();
const accessibleBoards = [];
for (const board of allBoards) {
if (board.isGuestAccessible) {
accessibleBoards.push(board);
continue;
}
const result = await permissionService.checkPermission(
EntityType.BOARD,
board.id,
user.id,
PermissionLevel.VIEW
);
if (result.hasPermission) {
accessibleBoards.push(board);
}
}
return { boards: accessibleBoards, isGuest: false };
};
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts">
import type { PageData } from './$types.js';
import BoardCard from '$lib/components/board/BoardCard.svelte';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>Boards</title>
</svelte:head>
<div class="mx-auto max-w-6xl px-4 py-8">
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">Boards</h1>
<p class="mt-1 text-sm text-gray-400">
{data.boards.length} board{data.boards.length === 1 ? '' : 's'} available
</p>
</div>
{#if !data.isGuest && data.user?.role === 'admin'}
<a
href="/boards/new"
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors"
>
New Board
</a>
{/if}
</div>
{#if data.boards.length === 0}
<div class="rounded-lg border border-gray-700 bg-gray-800/50 p-12 text-center">
<p class="text-gray-400">No boards available.</p>
{#if data.isGuest}
<p class="mt-2 text-sm text-gray-500">Sign in to see more boards.</p>
{/if}
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.boards as board (board.id)}
<BoardCard {board} />
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,61 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types.js';
import * as boardService from '$lib/server/services/boardService.js';
import * as permissionService from '$lib/server/services/permissionService.js';
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js';
export const load: PageServerLoad = async ({ params, locals }) => {
const { boardId } = params;
const user = locals.user;
// Permission check
if (!user) {
const isGuest = await isBoardGuestAccessible(boardId);
if (!isGuest) {
throw error(401, { message: 'Authentication required' });
}
} else if (user.role !== UserRole.ADMIN) {
const result = await permissionService.checkPermission(
EntityType.BOARD,
boardId,
user.id,
PermissionLevel.VIEW
);
if (!result.hasPermission) {
const isGuest = await isBoardGuestAccessible(boardId);
if (!isGuest) {
throw error(403, { message: 'Insufficient permissions' });
}
}
}
try {
// findBoardById includes sections -> widgets -> app -> statuses
const board = await boardService.findBoardById(boardId);
// Determine if user can edit this board
let canEdit = false;
if (user) {
if (user.role === UserRole.ADMIN) {
canEdit = true;
} else {
const editResult = await permissionService.checkPermission(
EntityType.BOARD,
boardId,
user.id,
PermissionLevel.EDIT
);
canEdit = editResult.hasPermission;
}
}
return { board, canEdit };
} catch (err) {
const message = err instanceof Error ? err.message : 'Board not found';
if (message.includes('not found')) {
throw error(404, { message: 'Board not found' });
}
throw error(500, { message });
}
};
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import type { PageData } from './$types.js';
import Board from '$lib/components/board/Board.svelte';
import BoardHeader from '$lib/components/board/BoardHeader.svelte';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{data.board.name}</title>
</svelte:head>
<div class="mx-auto max-w-7xl px-4 py-6">
<BoardHeader
name={data.board.name}
description={data.board.description}
icon={data.board.icon}
boardId={data.board.id}
canEdit={data.canEdit}
/>
<Board sections={data.board.sections} />
</div>
@@ -0,0 +1,198 @@
import { error, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types.js';
import * as boardService from '$lib/server/services/boardService.js';
import * as appService from '$lib/server/services/appService.js';
import * as permissionService from '$lib/server/services/permissionService.js';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
import {
updateBoardSchema,
createSectionSchema,
updateSectionSchema,
createWidgetSchema
} from '$lib/utils/validators.js';
export const load: PageServerLoad = async (event) => {
const user = requireAuth(event);
const { boardId } = event.params;
// Check edit permission
if (user.role !== UserRole.ADMIN) {
const result = await permissionService.checkPermission(
EntityType.BOARD,
boardId,
user.id,
PermissionLevel.EDIT
);
if (!result.hasPermission) {
throw error(403, { message: 'Insufficient permissions' });
}
}
try {
const board = await boardService.findBoardById(boardId);
const apps = await appService.findAll();
return { board, apps };
} catch (err) {
const message = err instanceof Error ? err.message : 'Board not found';
if (message.includes('not found')) {
throw error(404, { message: 'Board not found' });
}
throw error(500, { message });
}
};
export const actions: Actions = {
updateBoard: async (event) => {
requireAuth(event);
const { boardId } = event.params;
const formData = await event.request.formData();
const data = {
name: formData.get('name') as string | undefined,
icon: formData.get('icon') as string | undefined,
description: formData.get('description') as string | undefined,
isDefault: formData.get('isDefault') === 'on',
isGuestAccessible: formData.get('isGuestAccessible') === 'on'
};
const parsed = updateBoardSchema.safeParse(data);
if (!parsed.success) {
return { success: false, error: parsed.error.errors.map((e) => e.message).join(', ') };
}
try {
await boardService.updateBoard(boardId, parsed.data);
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to update board'
};
}
},
addSection: async (event) => {
requireAuth(event);
const { boardId } = event.params;
const formData = await event.request.formData();
const data = {
boardId,
title: formData.get('title') as string,
icon: (formData.get('icon') as string) || undefined,
isExpandedByDefault: formData.get('isExpandedByDefault') !== 'off'
};
const parsed = createSectionSchema.safeParse(data);
if (!parsed.success) {
return { success: false, error: parsed.error.errors.map((e) => e.message).join(', ') };
}
try {
await boardService.createSection(parsed.data);
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to add section'
};
}
},
updateSection: async (event) => {
requireAuth(event);
const formData = await event.request.formData();
const sectionId = formData.get('sectionId') as string;
const data = {
title: (formData.get('title') as string) || undefined,
icon: formData.get('icon') as string | undefined,
order: formData.get('order') ? Number(formData.get('order')) : undefined,
isExpandedByDefault:
formData.get('isExpandedByDefault') !== null
? formData.get('isExpandedByDefault') !== 'off'
: undefined
};
const parsed = updateSectionSchema.safeParse(data);
if (!parsed.success) {
return { success: false, error: parsed.error.errors.map((e) => e.message).join(', ') };
}
try {
await boardService.updateSection(sectionId, parsed.data);
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to update section'
};
}
},
deleteSection: async (event) => {
requireAuth(event);
const formData = await event.request.formData();
const sectionId = formData.get('sectionId') as string;
try {
await boardService.removeSection(sectionId);
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to delete section'
};
}
},
addWidget: async (event) => {
requireAuth(event);
const formData = await event.request.formData();
const sectionId = formData.get('sectionId') as string;
const appId = (formData.get('appId') as string) || undefined;
const type = (formData.get('type') as string) || 'app';
const config = appId ? JSON.stringify({ appId }) : '{}';
const data = {
sectionId,
type,
config,
appId
};
const parsed = createWidgetSchema.safeParse(data);
if (!parsed.success) {
return { success: false, error: parsed.error.errors.map((e) => e.message).join(', ') };
}
try {
await boardService.createWidget(parsed.data);
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to add widget'
};
}
},
deleteWidget: async (event) => {
requireAuth(event);
const formData = await event.request.formData();
const widgetId = formData.get('widgetId') as string;
try {
await boardService.removeWidget(widgetId);
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to delete widget'
};
}
}
};
@@ -0,0 +1,263 @@
<script lang="ts">
import type { PageData } from './$types.js';
import { enhance } from '$app/forms';
let { data }: { data: PageData } = $props();
let showAddSection = $state(false);
let addWidgetSectionId = $state<string | null>(null);
</script>
<svelte:head>
<title>Edit: {data.board.name}</title>
</svelte:head>
<div class="mx-auto max-w-4xl px-4 py-8">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-white">Edit Board</h1>
<a
href="/boards/{data.board.id}"
class="rounded-lg bg-gray-700 px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 transition-colors"
>
Back to Board
</a>
</div>
<!-- Board Properties -->
<section class="mb-8 rounded-lg border border-gray-700 bg-gray-800/50 p-6">
<h2 class="mb-4 text-lg font-semibold text-white">Board Properties</h2>
<form method="POST" action="?/updateBoard" use:enhance>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="board-name" class="mb-1 block text-sm font-medium text-gray-300">Name</label>
<input
id="board-name"
name="name"
type="text"
value={data.board.name}
class="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-sm text-white placeholder-gray-400 focus:border-indigo-500 focus:outline-none"
required
/>
</div>
<div>
<label for="board-icon" class="mb-1 block text-sm font-medium text-gray-300">Icon</label>
<input
id="board-icon"
name="icon"
type="text"
value={data.board.icon ?? ''}
class="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-sm text-white placeholder-gray-400 focus:border-indigo-500 focus:outline-none"
placeholder="e.g. layout-dashboard"
/>
</div>
<div class="sm:col-span-2">
<label for="board-desc" class="mb-1 block text-sm font-medium text-gray-300">Description</label>
<textarea
id="board-desc"
name="description"
rows="2"
class="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-sm text-white placeholder-gray-400 focus:border-indigo-500 focus:outline-none"
>{data.board.description ?? ''}</textarea>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-sm text-gray-300">
<input
type="checkbox"
name="isDefault"
checked={data.board.isDefault}
class="rounded border-gray-600 bg-gray-700"
/>
Default Board
</label>
<label class="flex items-center gap-2 text-sm text-gray-300">
<input
type="checkbox"
name="isGuestAccessible"
checked={data.board.isGuestAccessible}
class="rounded border-gray-600 bg-gray-700"
/>
Guest Accessible
</label>
</div>
</div>
<div class="mt-4">
<button
type="submit"
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors"
>
Save Board
</button>
</div>
</form>
</section>
<!-- Sections -->
<section class="mb-8">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-white">Sections</h2>
<button
type="button"
onclick={() => (showAddSection = !showAddSection)}
class="rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500 transition-colors"
>
{showAddSection ? 'Cancel' : 'Add Section'}
</button>
</div>
{#if showAddSection}
<div class="mb-4 rounded-lg border border-gray-700 bg-gray-800/50 p-4">
<form
method="POST"
action="?/addSection"
use:enhance={() => {
return async ({ update }) => {
await update();
showAddSection = false;
};
}}
>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label for="section-title" class="mb-1 block text-sm font-medium text-gray-300">Title</label>
<input
id="section-title"
name="title"
type="text"
class="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-sm text-white placeholder-gray-400 focus:border-indigo-500 focus:outline-none"
required
/>
</div>
<div>
<label for="section-icon" class="mb-1 block text-sm font-medium text-gray-300">Icon</label>
<input
id="section-icon"
name="icon"
type="text"
class="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-sm text-white placeholder-gray-400 focus:border-indigo-500 focus:outline-none"
placeholder="Optional"
/>
</div>
</div>
<div class="mt-3">
<button
type="submit"
class="rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-green-500 transition-colors"
>
Create Section
</button>
</div>
</form>
</div>
{/if}
{#if data.board.sections.length === 0}
<div class="rounded-lg border border-gray-700 bg-gray-800/50 p-8 text-center">
<p class="text-gray-400">No sections yet. Add one to get started.</p>
</div>
{:else}
<div class="space-y-4">
{#each data.board.sections as section (section.id)}
<div class="rounded-lg border border-gray-700 bg-gray-800/50 p-4">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium text-white">{section.title}</span>
<span class="text-xs text-gray-400">Order: {section.order}</span>
{#if section.icon}
<span class="text-xs text-gray-500">({section.icon})</span>
{/if}
</div>
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => (addWidgetSectionId = addWidgetSectionId === section.id ? null : section.id)}
class="rounded bg-indigo-600 px-2 py-1 text-xs font-medium text-white hover:bg-indigo-500 transition-colors"
>
Add Widget
</button>
<form method="POST" action="?/deleteSection" use:enhance>
<input type="hidden" name="sectionId" value={section.id} />
<button
type="submit"
class="rounded bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-500 transition-colors"
>
Delete
</button>
</form>
</div>
</div>
{#if addWidgetSectionId === section.id}
<div class="mb-3 rounded border border-gray-600 bg-gray-700/50 p-3">
<form
method="POST"
action="?/addWidget"
use:enhance={() => {
return async ({ update }) => {
await update();
addWidgetSectionId = null;
};
}}
>
<input type="hidden" name="sectionId" value={section.id} />
<input type="hidden" name="type" value="app" />
<div>
<label for="widget-app-{section.id}" class="mb-1 block text-sm font-medium text-gray-300">Select App</label>
<select
id="widget-app-{section.id}"
name="appId"
class="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-sm text-white focus:border-indigo-500 focus:outline-none"
required
>
<option value="">Choose an app...</option>
{#each data.apps as app (app.id)}
<option value={app.id}>{app.name}</option>
{/each}
</select>
</div>
<div class="mt-2">
<button
type="submit"
class="rounded bg-green-600 px-2 py-1 text-xs font-medium text-white hover:bg-green-500 transition-colors"
>
Add
</button>
</div>
</form>
</div>
{/if}
<!-- Widgets list -->
{#if section.widgets.length === 0}
<p class="text-sm text-gray-500">No widgets in this section.</p>
{:else}
<div class="space-y-2">
{#each section.widgets as widget (widget.id)}
<div class="flex items-center justify-between rounded border border-gray-600 bg-gray-700/30 px-3 py-2">
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-indigo-400 uppercase">{widget.type}</span>
{#if widget.app}
<span class="text-sm text-white">{widget.app.name}</span>
<span class="text-xs text-gray-400">({widget.app.url})</span>
{:else}
<span class="text-sm text-gray-400">Widget #{widget.order}</span>
{/if}
</div>
<form method="POST" action="?/deleteWidget" use:enhance>
<input type="hidden" name="widgetId" value={widget.id} />
<button
type="submit"
class="rounded bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-500 transition-colors"
>
Remove
</button>
</form>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</section>
</div>