Kalender: PG-adapter, API-ruter, månedsvisning med fargekoder
- Migrering 0003: calendars + calendar_events (nodes i kunnskapsgrafen) - REST API: GET kalender med tidsvindu, POST/PATCH/DELETE hendelser - PG polling-adapter med adapter-factory - CalendarBlock: månedsrutenett, heldags vs. tidshendelser, fargevelger - Seed: kalender for begge workspaces, kalenderside i sidekonfig Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
87eb2d4919
commit
d924645dd3
10 changed files with 1000 additions and 11 deletions
51
migrations/0003_calendar.sql
Normal file
51
migrations/0003_calendar.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
-- Sidelinja: Kalender-hendelser
|
||||||
|
-- Avhenger av: 0001_initial_schema.sql (nodes, workspaces)
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Kalendere (tilhører en workspace via nodes)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE calendars (
|
||||||
|
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||||
|
parent_id UUID NOT NULL REFERENCES nodes(id), -- Rot-node for workspace
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT, -- Standard fargekode for hendelser
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Kalender-hendelser
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE calendar_events (
|
||||||
|
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||||
|
calendar_id UUID NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
starts_at TIMESTAMPTZ NOT NULL,
|
||||||
|
ends_at TIMESTAMPTZ, -- NULL = heldagshendelse uten sluttid
|
||||||
|
all_day BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
color TEXT, -- Overstyrer kalender-farge
|
||||||
|
linked_node UUID REFERENCES nodes(id) ON DELETE SET NULL, -- Kobling til kanban-kort, episode, etc.
|
||||||
|
created_by TEXT REFERENCES users(authentik_id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_calendar_events_calendar ON calendar_events(calendar_id);
|
||||||
|
CREATE INDEX idx_calendar_events_time ON calendar_events(calendar_id, starts_at);
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_calendar_events_updated_at BEFORE UPDATE ON calendar_events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Utvid node_type
|
||||||
|
-- ============================================================
|
||||||
|
-- NB: ALTER TYPE ... ADD VALUE kan ikke kjøres inne i transaksjon i PG < 16
|
||||||
|
-- Derfor committes dette etter hovedtransaksjonen
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
ALTER TYPE node_type ADD VALUE IF NOT EXISTS 'calendar';
|
||||||
|
ALTER TYPE node_type ADD VALUE IF NOT EXISTS 'calendar_event';
|
||||||
|
|
@ -60,6 +60,12 @@ INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
||||||
INSERT INTO channels (id, parent_id, name) VALUES
|
INSERT INTO channels (id, parent_id, name) VALUES
|
||||||
('b0000000-0000-0000-0000-000000000012', 'b0000000-0000-0000-0000-000000000010', 'Styret');
|
('b0000000-0000-0000-0000-000000000012', 'b0000000-0000-0000-0000-000000000010', 'Styret');
|
||||||
|
|
||||||
|
-- Kalender for Liberalistene
|
||||||
|
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
||||||
|
('b0000000-0000-0000-0000-000000000030', 'b0000000-0000-0000-0000-000000000001', 'calendar');
|
||||||
|
INSERT INTO calendars (id, parent_id, name, color) VALUES
|
||||||
|
('b0000000-0000-0000-0000-000000000030', 'b0000000-0000-0000-0000-000000000010', 'Foreningskalender', '#f59e0b');
|
||||||
|
|
||||||
-- Kanban-brett for Liberalistene
|
-- Kanban-brett for Liberalistene
|
||||||
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
||||||
('b0000000-0000-0000-0000-000000000020', 'b0000000-0000-0000-0000-000000000001', 'kanban_board');
|
('b0000000-0000-0000-0000-000000000020', 'b0000000-0000-0000-0000-000000000001', 'kanban_board');
|
||||||
|
|
@ -85,6 +91,15 @@ UPDATE workspaces SET settings = jsonb_set(
|
||||||
{"id": "kanban-lib-1", "type": "kanban", "title": "Oppgaver", "props": {"boardId": "b0000000-0000-0000-0000-000000000020"}}
|
{"id": "kanban-lib-1", "type": "kanban", "title": "Oppgaver", "props": {"boardId": "b0000000-0000-0000-0000-000000000020"}}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"slug": "kalender",
|
||||||
|
"title": "Kalender",
|
||||||
|
"icon": "📅",
|
||||||
|
"layout": "single",
|
||||||
|
"blocks": [
|
||||||
|
{"id": "cal-lib-1", "type": "calendar", "title": "Foreningskalender", "props": {"calendarId": "b0000000-0000-0000-0000-000000000030"}}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"slug": "generelt",
|
"slug": "generelt",
|
||||||
"title": "Generelt",
|
"title": "Generelt",
|
||||||
|
|
@ -101,6 +116,12 @@ UPDATE workspaces SET settings = jsonb_set(
|
||||||
-- Sidelinja: Kanban-brett for redaksjonen
|
-- Sidelinja: Kanban-brett for redaksjonen
|
||||||
-- =============================================
|
-- =============================================
|
||||||
|
|
||||||
|
-- Kalender for Sidelinja
|
||||||
|
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
||||||
|
('a0000000-0000-0000-0000-000000000030', 'a0000000-0000-0000-0000-000000000001', 'calendar');
|
||||||
|
INSERT INTO calendars (id, parent_id, name, color) VALUES
|
||||||
|
('a0000000-0000-0000-0000-000000000030', 'a0000000-0000-0000-0000-000000000010', 'Redaksjonskalender', '#3b82f6');
|
||||||
|
|
||||||
-- Kanban-brett for redaksjonen
|
-- Kanban-brett for redaksjonen
|
||||||
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
||||||
('a0000000-0000-0000-0000-000000000020', 'a0000000-0000-0000-0000-000000000001', 'kanban_board');
|
('a0000000-0000-0000-0000-000000000020', 'a0000000-0000-0000-0000-000000000001', 'kanban_board');
|
||||||
|
|
@ -127,6 +148,15 @@ UPDATE workspaces SET settings = jsonb_set(
|
||||||
{"id": "kanban-1", "type": "kanban", "title": "Planlegging", "props": {"boardId": "a0000000-0000-0000-0000-000000000020"}}
|
{"id": "kanban-1", "type": "kanban", "title": "Planlegging", "props": {"boardId": "a0000000-0000-0000-0000-000000000020"}}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"slug": "kalender",
|
||||||
|
"title": "Kalender",
|
||||||
|
"icon": "📅",
|
||||||
|
"layout": "single",
|
||||||
|
"blocks": [
|
||||||
|
{"id": "cal-1", "type": "calendar", "title": "Redaksjonskalender", "props": {"calendarId": "a0000000-0000-0000-0000-000000000030"}}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"slug": "research",
|
"slug": "research",
|
||||||
"title": "Research",
|
"title": "Research",
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,625 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { createCalendar } from '$lib/calendar/create.svelte';
|
||||||
|
import type { CalendarConnection, CalendarEvent } from '$lib/calendar/types';
|
||||||
|
|
||||||
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||||
|
|
||||||
|
const calendarId = props.calendarId as string | undefined;
|
||||||
|
|
||||||
|
let cal = $state<CalendarConnection | null>(null);
|
||||||
|
|
||||||
|
const EVENT_COLORS = [
|
||||||
|
{ value: '#3b82f6', label: 'Blå' },
|
||||||
|
{ value: '#10b981', label: 'Grønn' },
|
||||||
|
{ value: '#f59e0b', label: 'Gul' },
|
||||||
|
{ value: '#ef4444', label: 'Rød' },
|
||||||
|
{ value: '#8b5cf6', label: 'Lilla' },
|
||||||
|
{ value: '#ec4899', label: 'Rosa' },
|
||||||
|
{ value: '#6b7280', label: 'Grå' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Ny hendelse
|
||||||
|
let showAdd = $state(false);
|
||||||
|
let addDate = $state('');
|
||||||
|
let addTitle = $state('');
|
||||||
|
let addAllDay = $state(true);
|
||||||
|
let addStartTime = $state('09:00');
|
||||||
|
let addEndTime = $state('10:00');
|
||||||
|
let addColor = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Redigering
|
||||||
|
let editingEvent = $state<CalendarEvent | null>(null);
|
||||||
|
let editTitle = $state('');
|
||||||
|
let editDescription = $state('');
|
||||||
|
let editStartsAt = $state('');
|
||||||
|
let editEndsAt = $state('');
|
||||||
|
let editAllDay = $state(false);
|
||||||
|
let editColor = $state<string | null>(null);
|
||||||
|
|
||||||
|
let viewDate = $derived(cal?.viewDate ?? new Date());
|
||||||
|
let events = $derived(cal?.events ?? []);
|
||||||
|
|
||||||
|
let year = $derived(viewDate.getFullYear());
|
||||||
|
let month = $derived(viewDate.getMonth());
|
||||||
|
let monthName = $derived(viewDate.toLocaleString('nb-NO', { month: 'long', year: 'numeric' }));
|
||||||
|
|
||||||
|
// Bygg kalenderrutenett
|
||||||
|
let calendarDays = $derived.by(() => {
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
|
||||||
|
// Mandag = 0
|
||||||
|
let startDow = firstDay.getDay() - 1;
|
||||||
|
if (startDow < 0) startDow = 6;
|
||||||
|
|
||||||
|
const days: { date: Date; inMonth: boolean; dateStr: string }[] = [];
|
||||||
|
|
||||||
|
// Dager fra forrige måned
|
||||||
|
for (let i = startDow - 1; i >= 0; i--) {
|
||||||
|
const d = new Date(year, month, -i);
|
||||||
|
days.push({ date: d, inMonth: false, dateStr: fmt(d) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dager i måneden
|
||||||
|
for (let i = 1; i <= lastDay.getDate(); i++) {
|
||||||
|
const d = new Date(year, month, i);
|
||||||
|
days.push({ date: d, inMonth: true, dateStr: fmt(d) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fyll ut til 42 (6 rader)
|
||||||
|
while (days.length < 42) {
|
||||||
|
const d = new Date(year, month + 1, days.length - startDow - lastDay.getDate() + 1);
|
||||||
|
days.push({ date: d, inMonth: false, dateStr: fmt(d) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
});
|
||||||
|
|
||||||
|
function fmt(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToday(dateStr: string): boolean {
|
||||||
|
return dateStr === fmt(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventsForDate(dateStr: string): CalendarEvent[] {
|
||||||
|
return events.filter((e) => {
|
||||||
|
const d = new Date(e.starts_at);
|
||||||
|
const eDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
return eDate === dateStr;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(isoStr: string): string {
|
||||||
|
const d = new Date(isoStr);
|
||||||
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevMonth() {
|
||||||
|
cal?.setViewDate(new Date(year, month - 1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMonth() {
|
||||||
|
cal?.setViewDate(new Date(year, month + 1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToday() {
|
||||||
|
cal?.setViewDate(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdd(dateStr: string) {
|
||||||
|
addDate = dateStr;
|
||||||
|
addTitle = '';
|
||||||
|
addAllDay = true;
|
||||||
|
addStartTime = '09:00';
|
||||||
|
addEndTime = '10:00';
|
||||||
|
addColor = null;
|
||||||
|
showAdd = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
if (!cal || !addTitle.trim()) return;
|
||||||
|
|
||||||
|
let starts_at: string;
|
||||||
|
let ends_at: string | null = null;
|
||||||
|
|
||||||
|
if (addAllDay) {
|
||||||
|
starts_at = `${addDate}T12:00:00`;
|
||||||
|
} else {
|
||||||
|
starts_at = new Date(`${addDate}T${addStartTime}`).toISOString();
|
||||||
|
ends_at = new Date(`${addDate}T${addEndTime}`).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
await cal.addEvent({
|
||||||
|
title: addTitle.trim(),
|
||||||
|
starts_at,
|
||||||
|
ends_at,
|
||||||
|
all_day: addAllDay,
|
||||||
|
color: addColor
|
||||||
|
});
|
||||||
|
showAdd = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(event: CalendarEvent) {
|
||||||
|
editingEvent = event;
|
||||||
|
editTitle = event.title;
|
||||||
|
editDescription = event.description ?? '';
|
||||||
|
editAllDay = event.all_day;
|
||||||
|
editStartsAt = event.starts_at.slice(0, 16);
|
||||||
|
editEndsAt = event.ends_at?.slice(0, 16) ?? '';
|
||||||
|
editColor = event.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!cal || !editingEvent) return;
|
||||||
|
await cal.updateEvent(editingEvent.id, {
|
||||||
|
title: editTitle.trim() || editingEvent.title,
|
||||||
|
description: editDescription.trim() || null,
|
||||||
|
starts_at: editStartsAt ? new Date(editStartsAt).toISOString() : undefined,
|
||||||
|
ends_at: editEndsAt ? new Date(editEndsAt).toISOString() : null,
|
||||||
|
all_day: editAllDay,
|
||||||
|
color: editColor
|
||||||
|
});
|
||||||
|
editingEvent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteEvent() {
|
||||||
|
if (!cal || !editingEvent) return;
|
||||||
|
await cal.deleteEvent(editingEvent.id);
|
||||||
|
editingEvent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (editingEvent || showAdd) {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
editingEvent = null;
|
||||||
|
showAdd = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (calendarId) {
|
||||||
|
cal = createCalendar(calendarId);
|
||||||
|
}
|
||||||
|
return () => cal?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const WEEKDAYS = ['Man', 'Tir', 'Ons', 'Tor', 'Fre', 'Lør', 'Søn'];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="placeholder">
|
{#if !calendarId}
|
||||||
<span class="icon">📅</span>
|
<div class="no-cal"><p>Ingen kalender konfigurert for denne blokken.</p></div>
|
||||||
<p class="label">Kalender</p>
|
{:else if cal?.loading}
|
||||||
<p class="hint">Kommer snart</p>
|
<div class="no-cal"><p>Laster...</p></div>
|
||||||
|
{:else}
|
||||||
|
<div class="calendar-wrapper">
|
||||||
|
<div class="calendar-header">
|
||||||
|
<button type="button" class="nav-btn" onclick={prevMonth}>←</button>
|
||||||
|
<button type="button" class="month-name" onclick={goToday}>{monthName}</button>
|
||||||
|
<button type="button" class="nav-btn" onclick={nextMonth}>→</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="weekday-row">
|
||||||
|
{#each WEEKDAYS as day}
|
||||||
|
<div class="weekday">{day}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="days-grid">
|
||||||
|
{#each calendarDays as day}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="day-cell"
|
||||||
|
class:out-of-month={!day.inMonth}
|
||||||
|
class:today={isToday(day.dateStr)}
|
||||||
|
onclick={() => openAdd(day.dateStr)}
|
||||||
|
>
|
||||||
|
<span class="day-number">{day.date.getDate()}</span>
|
||||||
|
{#each eventsForDate(day.dateStr) as event}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="event-pill"
|
||||||
|
class:all-day={event.all_day}
|
||||||
|
style:background={event.color || cal?.calendar?.color || '#3b82f6'}
|
||||||
|
onclick={(e) => { e.stopPropagation(); openEdit(event); }}
|
||||||
|
>
|
||||||
|
{#if !event.all_day}<span class="event-time">{formatTime(event.starts_at)}</span>{/if}
|
||||||
|
{event.title}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showAdd}
|
||||||
|
<div class="modal-backdrop" onclick={() => { showAdd = false; }} role="presentation">
|
||||||
|
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1">
|
||||||
|
<h3>Ny hendelse — {addDate}</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="edit-input"
|
||||||
|
placeholder="Tittel"
|
||||||
|
bind:value={addTitle}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Enter') handleAdd(); }}
|
||||||
|
/>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" bind:checked={addAllDay} />
|
||||||
|
Hele dagen
|
||||||
|
</label>
|
||||||
|
{#if !addAllDay}
|
||||||
|
<div class="time-row">
|
||||||
|
<input type="time" bind:value={addStartTime} class="edit-input" />
|
||||||
|
<span class="time-sep">—</span>
|
||||||
|
<input type="time" bind:value={addEndTime} class="edit-input" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="color-picker">
|
||||||
|
{#each EVENT_COLORS as c}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="color-dot"
|
||||||
|
class:selected={addColor === c.value}
|
||||||
|
style:background={c.value}
|
||||||
|
title={c.label}
|
||||||
|
onclick={() => { addColor = addColor === c.value ? null : c.value; }}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" onclick={handleAdd} disabled={!addTitle.trim()}>Opprett</button>
|
||||||
|
<button type="button" class="cancel" onclick={() => { showAdd = false; }}>Avbryt</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editingEvent}
|
||||||
|
<div class="modal-backdrop" onclick={() => { editingEvent = null; }} role="presentation">
|
||||||
|
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="edit-input title-input"
|
||||||
|
bind:value={editTitle}
|
||||||
|
placeholder="Tittel"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
class="edit-input"
|
||||||
|
bind:value={editDescription}
|
||||||
|
placeholder="Beskrivelse (valgfritt)"
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" bind:checked={editAllDay} />
|
||||||
|
Hele dagen
|
||||||
|
</label>
|
||||||
|
{#if !editAllDay}
|
||||||
|
<div class="time-row">
|
||||||
|
<input type="datetime-local" bind:value={editStartsAt} class="edit-input" />
|
||||||
|
<span class="time-sep">—</span>
|
||||||
|
<input type="datetime-local" bind:value={editEndsAt} class="edit-input" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="color-picker">
|
||||||
|
{#each EVENT_COLORS as c}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="color-dot"
|
||||||
|
class:selected={editColor === c.value}
|
||||||
|
style:background={c.value}
|
||||||
|
title={c.label}
|
||||||
|
onclick={() => { editColor = editColor === c.value ? null : c.value; }}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" onclick={saveEdit}>Lagre</button>
|
||||||
|
<button type="button" class="delete" onclick={handleDeleteEvent}>Slett</button>
|
||||||
|
<button type="button" class="cancel" onclick={() => { editingEvent = null; }}>Avbryt</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if cal?.error}
|
||||||
|
<div class="error">{cal.error}</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.placeholder {
|
.calendar-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e1e4e8;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-name:hover {
|
||||||
|
background: #1e2235;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #8b92a5;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.days-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell {
|
||||||
|
background: #0f1117;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.15rem;
|
||||||
|
min-height: 3.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
text-align: left;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell:hover {
|
||||||
|
border-color: #2d3148;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.out-of-month {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.today {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-number {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
padding: 0.1rem 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today .day-number {
|
||||||
|
color: #3b82f6;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-pill {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: white;
|
||||||
|
padding: 0.1rem 0.25rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-top: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-pill.all-day {
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-time {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-pill:hover {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-dot {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-dot:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-dot.selected {
|
||||||
|
border-color: #e1e4e8;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modaler */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: #161822;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
width: 340px;
|
||||||
|
max-width: 90vw;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #e1e4e8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-input {
|
||||||
|
background: #0f1117;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e1e4e8;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-input::placeholder {
|
||||||
|
color: #8b92a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-input {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.edit-input {
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-sep {
|
||||||
|
color: #8b92a5;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.4rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button.delete {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button.cancel {
|
||||||
|
background: #1e2235;
|
||||||
|
color: #8b92a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-cal {
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 200px;
|
|
||||||
color: #8b92a5;
|
color: #8b92a5;
|
||||||
gap: 0.5rem;
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #f87171;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
}
|
}
|
||||||
.icon { font-size: 2rem; }
|
|
||||||
.label { font-weight: 600; color: #e1e4e8; }
|
|
||||||
.hint { font-size: 0.8rem; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
6
web/src/lib/calendar/create.svelte.ts
Normal file
6
web/src/lib/calendar/create.svelte.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { CalendarConnection } from './types';
|
||||||
|
import { createPgCalendar } from './pg.svelte';
|
||||||
|
|
||||||
|
export function createCalendar(calendarId: string): CalendarConnection {
|
||||||
|
return createPgCalendar(calendarId);
|
||||||
|
}
|
||||||
2
web/src/lib/calendar/index.ts
Normal file
2
web/src/lib/calendar/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export type { CalendarEvent, Calendar, CalendarConnection } from './types';
|
||||||
|
export { createCalendar } from './create.svelte';
|
||||||
101
web/src/lib/calendar/pg.svelte.ts
Normal file
101
web/src/lib/calendar/pg.svelte.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import type { Calendar, CalendarEvent, CalendarConnection } from './types';
|
||||||
|
|
||||||
|
export function createPgCalendar(calendarId: string): CalendarConnection {
|
||||||
|
let _calendar = $state<Calendar | null>(null);
|
||||||
|
let _events = $state<CalendarEvent[]>([]);
|
||||||
|
let _error = $state('');
|
||||||
|
let _loading = $state(true);
|
||||||
|
let _viewDate = $state(new Date());
|
||||||
|
let _interval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function getMonthRange(date: Date): { from: string; to: string } {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth();
|
||||||
|
// Hent fra 6 dager før månedens start (for kalendervisning) til 6 dager etter slutt
|
||||||
|
const from = new Date(year, month, -6);
|
||||||
|
const to = new Date(year, month + 1, 7);
|
||||||
|
return {
|
||||||
|
from: from.toISOString(),
|
||||||
|
to: to.toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchEvents() {
|
||||||
|
try {
|
||||||
|
const { from, to } = getMonthRange(_viewDate);
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/calendar/${calendarId}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
_error = `Feil: ${res.status}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
_calendar = { id: data.id, name: data.name, color: data.color, events: data.events };
|
||||||
|
_events = data.events;
|
||||||
|
_error = '';
|
||||||
|
} catch (e) {
|
||||||
|
_error = e instanceof Error ? e.message : 'Ukjent feil';
|
||||||
|
} finally {
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchEvents();
|
||||||
|
_interval = setInterval(fetchEvents, 5000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get calendar() { return _calendar; },
|
||||||
|
get events() { return _events; },
|
||||||
|
get error() { return _error; },
|
||||||
|
get loading() { return _loading; },
|
||||||
|
get viewDate() { return _viewDate; },
|
||||||
|
|
||||||
|
setViewDate(date: Date) {
|
||||||
|
_viewDate = date;
|
||||||
|
_loading = true;
|
||||||
|
fetchEvents();
|
||||||
|
},
|
||||||
|
|
||||||
|
async addEvent(event) {
|
||||||
|
const res = await fetch(`/api/calendar/${calendarId}/events`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(event)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
_error = `Feil: ${res.status}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchEvents();
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateEvent(eventId, updates) {
|
||||||
|
const res = await fetch(`/api/calendar/${calendarId}/events/${eventId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updates)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
_error = `Feil: ${res.status}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchEvents();
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteEvent(eventId) {
|
||||||
|
const res = await fetch(`/api/calendar/${calendarId}/events/${eventId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
_error = `Feil: ${res.status}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchEvents();
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (_interval) clearInterval(_interval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
50
web/src/lib/calendar/types.ts
Normal file
50
web/src/lib/calendar/types.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
export interface CalendarEvent {
|
||||||
|
id: string;
|
||||||
|
calendar_id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
starts_at: string;
|
||||||
|
ends_at: string | null;
|
||||||
|
all_day: boolean;
|
||||||
|
color: string | null;
|
||||||
|
linked_node: string | null;
|
||||||
|
created_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Calendar {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string | null;
|
||||||
|
events: CalendarEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarConnection {
|
||||||
|
readonly calendar: Calendar | null;
|
||||||
|
readonly events: CalendarEvent[];
|
||||||
|
readonly error: string;
|
||||||
|
readonly loading: boolean;
|
||||||
|
readonly viewDate: Date;
|
||||||
|
setViewDate(date: Date): void;
|
||||||
|
addEvent(event: {
|
||||||
|
title: string;
|
||||||
|
starts_at: string;
|
||||||
|
ends_at?: string | null;
|
||||||
|
all_day?: boolean;
|
||||||
|
description?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
}): Promise<void>;
|
||||||
|
updateEvent(
|
||||||
|
eventId: string,
|
||||||
|
updates: {
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
starts_at?: string;
|
||||||
|
ends_at?: string | null;
|
||||||
|
all_day?: boolean;
|
||||||
|
color?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<void>;
|
||||||
|
deleteEvent(eventId: string): Promise<void>;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
50
web/src/routes/api/calendar/[calendarId]/+server.ts
Normal file
50
web/src/routes/api/calendar/[calendarId]/+server.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/** GET /api/calendar/:calendarId?from=...&to=... — Hent kalender med hendelser i tidsvindu */
|
||||||
|
export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const calendarId = params.calendarId;
|
||||||
|
|
||||||
|
const [calendar] = await sql`
|
||||||
|
SELECT c.id, c.name, c.color FROM calendars c
|
||||||
|
JOIN nodes n ON n.id = c.id
|
||||||
|
WHERE c.id = ${calendarId} AND n.workspace_id = ${locals.workspace.id}
|
||||||
|
`;
|
||||||
|
if (!calendar) error(404, 'Kalender ikke funnet');
|
||||||
|
|
||||||
|
const from = url.searchParams.get('from');
|
||||||
|
const to = url.searchParams.get('to');
|
||||||
|
|
||||||
|
let events;
|
||||||
|
if (from && to) {
|
||||||
|
events = await sql`
|
||||||
|
SELECT e.id, e.calendar_id, e.title, e.description,
|
||||||
|
e.starts_at, e.ends_at, e.all_day, e.color,
|
||||||
|
e.linked_node, e.created_by, e.created_at
|
||||||
|
FROM calendar_events e
|
||||||
|
WHERE e.calendar_id = ${calendarId}
|
||||||
|
AND e.starts_at < ${to}
|
||||||
|
AND (e.ends_at IS NULL OR e.ends_at > ${from} OR e.starts_at >= ${from})
|
||||||
|
ORDER BY e.starts_at ASC
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
events = await sql`
|
||||||
|
SELECT e.id, e.calendar_id, e.title, e.description,
|
||||||
|
e.starts_at, e.ends_at, e.all_day, e.color,
|
||||||
|
e.linked_node, e.created_by, e.created_at
|
||||||
|
FROM calendar_events e
|
||||||
|
WHERE e.calendar_id = ${calendarId}
|
||||||
|
ORDER BY e.starts_at ASC
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
id: calendar.id,
|
||||||
|
name: calendar.name,
|
||||||
|
color: calendar.color,
|
||||||
|
events
|
||||||
|
});
|
||||||
|
};
|
||||||
46
web/src/routes/api/calendar/[calendarId]/events/+server.ts
Normal file
46
web/src/routes/api/calendar/[calendarId]/events/+server.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/** POST /api/calendar/:calendarId/events — Opprett hendelse */
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const calendarId = params.calendarId;
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (!body.title?.trim() || !body.starts_at) {
|
||||||
|
error(400, 'title og starts_at er påkrevd');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifiser tilgang
|
||||||
|
const [calendar] = await sql`
|
||||||
|
SELECT c.id FROM calendars c
|
||||||
|
JOIN nodes n ON n.id = c.id
|
||||||
|
WHERE c.id = ${calendarId} AND n.workspace_id = ${locals.workspace.id}
|
||||||
|
`;
|
||||||
|
if (!calendar) error(404, 'Kalender ikke funnet');
|
||||||
|
|
||||||
|
const [event] = await sql`
|
||||||
|
WITH new_node AS (
|
||||||
|
INSERT INTO nodes (workspace_id, node_type)
|
||||||
|
VALUES (${locals.workspace.id}, 'calendar_event')
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
INSERT INTO calendar_events (id, calendar_id, title, description, starts_at, ends_at, all_day, color, created_by)
|
||||||
|
SELECT
|
||||||
|
new_node.id,
|
||||||
|
${calendarId},
|
||||||
|
${body.title.trim()},
|
||||||
|
${body.description?.trim() || null},
|
||||||
|
${body.starts_at},
|
||||||
|
${body.ends_at || null},
|
||||||
|
${body.all_day ?? false},
|
||||||
|
${body.color || null},
|
||||||
|
${locals.user.id}
|
||||||
|
FROM new_node
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
return json(event, { status: 201 });
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/** PATCH /api/calendar/:calendarId/events/:eventId — Oppdater hendelse */
|
||||||
|
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const { calendarId, eventId } = params;
|
||||||
|
const updates = await request.json();
|
||||||
|
|
||||||
|
// Verifiser tilgang
|
||||||
|
const [event] = await sql`
|
||||||
|
SELECT e.id FROM calendar_events e
|
||||||
|
JOIN calendars c ON c.id = e.calendar_id
|
||||||
|
JOIN nodes n ON n.id = c.id
|
||||||
|
WHERE e.id = ${eventId} AND c.id = ${calendarId} AND n.workspace_id = ${locals.workspace.id}
|
||||||
|
`;
|
||||||
|
if (!event) error(404, 'Hendelse ikke funnet');
|
||||||
|
|
||||||
|
const [updated] = await sql`
|
||||||
|
UPDATE calendar_events SET
|
||||||
|
title = COALESCE(${updates.title ?? null}, title),
|
||||||
|
description = CASE WHEN ${updates.description !== undefined} THEN ${updates.description ?? null} ELSE description END,
|
||||||
|
starts_at = COALESCE(${updates.starts_at ?? null}, starts_at),
|
||||||
|
ends_at = CASE WHEN ${updates.ends_at !== undefined} THEN ${updates.ends_at ?? null} ELSE ends_at END,
|
||||||
|
all_day = COALESCE(${updates.all_day ?? null}, all_day),
|
||||||
|
color = CASE WHEN ${updates.color !== undefined} THEN ${updates.color ?? null} ELSE color END
|
||||||
|
WHERE id = ${eventId}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
return json(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** DELETE /api/calendar/:calendarId/events/:eventId — Slett hendelse */
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const { calendarId, eventId } = params;
|
||||||
|
|
||||||
|
const [event] = await sql`
|
||||||
|
SELECT e.id FROM calendar_events e
|
||||||
|
JOIN calendars c ON c.id = e.calendar_id
|
||||||
|
JOIN nodes n ON n.id = c.id
|
||||||
|
WHERE e.id = ${eventId} AND c.id = ${calendarId} AND n.workspace_id = ${locals.workspace.id}
|
||||||
|
`;
|
||||||
|
if (!event) error(404, 'Hendelse ikke funnet');
|
||||||
|
|
||||||
|
await sql`DELETE FROM nodes WHERE id = ${eventId}`;
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
};
|
||||||
Loading…
Add table
Reference in a new issue