Gjør CalendarTrait til fullverdig BlockShell-panel med inline kalender (oppgave 20.7)

CalendarTrait var tidligere en stub med lenke til /calendar-ruten.
Nå er hele kalender-UI-et innebygd direkte i panelet:

- Månedsgrid med navigasjon (forrige/neste/i dag)
- Hendelser vises som fargekodede chips i dagceller
- Drag-and-drop mellom datoer for å flytte hendelser
- Inline opprettelse av nye hendelser (tittel + valgfri tid)
- setDragPayload for å dra hendelser til andre paneler
- BlockReceiver: aksepterer content/communication-noder fra chat/kanban
- Responsivt med @container queries for små paneler
- Hendelsesliste under grid for oversikt over måneden

Følger samme mønster som ChatTrait (20.5) og KanbanTrait (20.6).
accessToken sendes nå til CalendarTrait fra collection-siden.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 08:40:36 +00:00
parent 8a029bb280
commit 9035f09667
3 changed files with 880 additions and 41 deletions

View file

@ -1,19 +1,22 @@
<script lang="ts">
import type { Node } from '$lib/spacetime';
import { edgeStore, nodeStore } from '$lib/spacetime';
import { checkCalendarCompat, type DragPayload } from '$lib/transfer';
import type { Node, Edge } from '$lib/spacetime';
import { edgeStore, nodeStore, nodeVisibility, connectionState } from '$lib/spacetime';
import { createNode, createEdge, updateEdge } from '$lib/api';
import { setDragPayload, checkCalendarCompat, type DragPayload } from '$lib/transfer';
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
import TraitPanel from './TraitPanel.svelte';
interface Props {
collection: Node;
config: Record<string, unknown>;
userId?: string;
accessToken?: string;
/** Called when a drop is received on this panel */
onReceiveDrop?: (payload: DragPayload, intent: PlacementIntent) => void;
}
let { collection, config, userId, onReceiveDrop }: Props = $props();
let { collection, config, userId, accessToken, onReceiveDrop }: Props = $props();
const connected = $derived(connectionState.current === 'connected');
/**
* BlockReceiver implementation for Calendar.
@ -39,45 +42,882 @@
}
};
/** Scheduled events connected to this collection */
const events = $derived.by(() => {
const items: { node: Node; when: string }[] = [];
for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'scheduled') continue;
// =========================================================================
// Calendar state
// =========================================================================
const today = new Date();
let viewYear = $state(today.getFullYear());
let viewMonth = $state(today.getMonth()); // 0-indexed
const monthNames = [
'Januar', 'Februar', 'Mars', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Desember'
];
const dayNames = ['Ma', 'Ti', 'On', 'To', 'Fr', 'Lø', 'Sø'];
function prevMonth() {
if (viewMonth === 0) { viewMonth = 11; viewYear--; }
else { viewMonth--; }
}
function nextMonth() {
if (viewMonth === 11) { viewMonth = 0; viewYear++; }
else { viewMonth++; }
}
function goToday() {
viewYear = today.getFullYear();
viewMonth = today.getMonth();
}
// =========================================================================
// Calendar grid computation
// =========================================================================
interface CalendarDay {
date: Date;
dayOfMonth: number;
isCurrentMonth: boolean;
isToday: boolean;
dateKey: string;
}
function formatDateKey(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
const calendarDays = $derived.by((): CalendarDay[] => {
const firstOfMonth = new Date(viewYear, viewMonth, 1);
let startDow = firstOfMonth.getDay() - 1;
if (startDow < 0) startDow = 6;
const days: CalendarDay[] = [];
const startDate = new Date(viewYear, viewMonth, 1 - startDow);
const todayKey = formatDateKey(today);
for (let i = 0; i < 42; i++) {
const d = new Date(startDate);
d.setDate(startDate.getDate() + i);
const dateKey = formatDateKey(d);
days.push({
date: d,
dayOfMonth: d.getDate(),
isCurrentMonth: d.getMonth() === viewMonth && d.getFullYear() === viewYear,
isToday: dateKey === todayKey,
dateKey
});
}
return days;
});
// =========================================================================
// Scheduled events from SpacetimeDB
// =========================================================================
interface ScheduledEvent {
node: Node;
edge: Edge;
scheduledAt: Date;
dateKey: string;
timeStr: string;
}
const scheduledEvents = $derived.by((): ScheduledEvent[] => {
if (!connected || !userId) return [];
const events: ScheduledEvent[] = [];
for (const edge of edgeStore.byType('scheduled')) {
const node = nodeStore.get(edge.sourceId);
if (!node) continue;
let when = '';
if (!node || nodeVisibility(node, userId) === 'hidden') continue;
let at: string | undefined;
try {
const meta = JSON.parse(edge.metadata ?? '{}');
when = meta.scheduled_at ?? meta.date ?? '';
at = meta.at;
} catch { /* ignore */ }
items.push({ node, when });
if (!at) continue;
const scheduledAt = new Date(at);
if (isNaN(scheduledAt.getTime())) continue;
const dateKey = formatDateKey(scheduledAt);
const hours = scheduledAt.getHours();
const minutes = scheduledAt.getMinutes();
const isAllDay = hours === 12 && minutes === 0;
const timeStr = isAllDay
? ''
: `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
events.push({ node, edge, scheduledAt, dateKey, timeStr });
}
items.sort((a, b) => a.when.localeCompare(b.when));
return items;
events.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
return events;
});
const eventsByDate = $derived.by((): Map<string, ScheduledEvent[]> => {
const map = new Map<string, ScheduledEvent[]>();
for (const ev of scheduledEvents) {
const list = map.get(ev.dateKey);
if (list) { list.push(ev); }
else { map.set(ev.dateKey, [ev]); }
}
return map;
});
// =========================================================================
// Internal drag-and-drop (move events between dates)
// =========================================================================
let draggedEvent = $state<ScheduledEvent | null>(null);
let dragOverDate = $state<string | null>(null);
function handleDragStart(e: DragEvent, event: ScheduledEvent) {
draggedEvent = event;
if (!e.dataTransfer) return;
e.dataTransfer.effectAllowed = 'move';
// Set cross-panel payload for dragging events to other panels
setDragPayload(e.dataTransfer, {
nodeId: event.node.id,
nodeKind: event.node.nodeKind,
sourcePanel: 'calendar',
});
}
function handleDragOver(e: DragEvent, dateKey: string) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
dragOverDate = dateKey;
}
function handleDragLeave() {
dragOverDate = null;
}
async function handleDrop(e: DragEvent, dateKey: string) {
e.preventDefault();
dragOverDate = null;
if (!draggedEvent || !accessToken) return;
if (draggedEvent.dateKey === dateKey) {
draggedEvent = null;
return;
}
const event = draggedEvent;
draggedEvent = null;
const [y, m, d] = dateKey.split('-').map(Number);
const newDate = new Date(y, m - 1, d,
event.scheduledAt.getHours(),
event.scheduledAt.getMinutes()
);
try {
await updateEdge(accessToken, {
edge_id: event.edge.id,
metadata: { at: newDate.toISOString() }
});
} catch (err) {
console.error('Feil ved flytting av hendelse:', err);
}
}
function handleDragEnd() {
draggedEvent = null;
dragOverDate = null;
}
// =========================================================================
// Create new scheduled event
// =========================================================================
let addingToDate = $state<string | null>(null);
let newEventTitle = $state('');
let newEventTime = $state('');
let isCreating = $state(false);
function startAddEvent(dateKey: string) {
addingToDate = dateKey;
newEventTitle = '';
newEventTime = '';
}
async function handleCreateEvent() {
if (!accessToken || !userId || !addingToDate || !newEventTitle.trim() || isCreating) return;
isCreating = true;
try {
const [y, m, d] = addingToDate.split('-').map(Number);
let scheduledDate: Date;
if (newEventTime) {
const [h, min] = newEventTime.split(':').map(Number);
scheduledDate = new Date(y, m - 1, d, h, min);
} else {
scheduledDate = new Date(y, m - 1, d, 12, 0);
}
const { node_id } = await createNode(accessToken, {
node_kind: 'content',
title: newEventTitle.trim(),
visibility: 'hidden'
});
await createEdge(accessToken, {
source_id: userId,
target_id: node_id,
edge_type: 'owner'
});
await createEdge(accessToken, {
source_id: node_id,
target_id: node_id,
edge_type: 'scheduled',
metadata: { at: scheduledDate.toISOString() }
});
newEventTitle = '';
newEventTime = '';
addingToDate = null;
} catch (err) {
console.error('Feil ved oppretting av hendelse:', err);
} finally {
isCreating = false;
}
}
function handleEventKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateEvent();
} else if (e.key === 'Escape') {
addingToDate = null;
newEventTitle = '';
newEventTime = '';
}
}
// =========================================================================
// Event colors based on node_kind
// =========================================================================
function eventColorClass(node: Node): string {
switch (node.nodeKind) {
case 'communication': return 'cal-event-comm';
case 'media': return 'cal-event-media';
case 'collection': return 'cal-event-coll';
default: return 'cal-event-default';
}
}
</script>
<TraitPanel name="calendar" label="Kalender" icon="📅">
{#snippet children()}
<a
href="/calendar"
class="mb-3 inline-flex items-center gap-1.5 rounded bg-amber-100 px-3 py-1.5 text-xs font-medium text-amber-800 hover:bg-amber-200"
>
Åpne kalender &rarr;
</a>
{#if events.length > 0}
<ul class="mt-2 space-y-1">
{#each events.slice(0, 5) as ev (ev.node.id)}
<li class="flex items-center gap-2 text-sm">
<span class="text-xs text-gray-400">{ev.when || '—'}</span>
<span class="text-gray-900">{ev.node.title || 'Hendelse'}</span>
</li>
<!--
CalendarTrait — fullverdig BlockShell-panel for kalender.
Viser månedsgrid med hendelser, drag-and-drop mellom datoer,
inline opprettelse, og støtter mottak av noder fra andre paneler.
Forelder (collection page) wrapper dette i BlockShell.
-->
<div class="cal-trait">
{#if !connected}
<div class="cal-empty">
<p>Venter på tilkobling…</p>
</div>
{:else}
<!-- Month navigation -->
<div class="cal-nav">
<button onclick={prevMonth} class="cal-nav-btn" aria-label="Forrige måned">&larr;</button>
<div class="cal-nav-center">
<span class="cal-nav-title">{monthNames[viewMonth]} {viewYear}</span>
<button onclick={goToday} class="cal-today-btn">I dag</button>
</div>
<button onclick={nextMonth} class="cal-nav-btn" aria-label="Neste måned">&rarr;</button>
</div>
<!-- Calendar grid -->
<div class="cal-grid-wrapper">
<!-- Day headers -->
<div class="cal-day-headers">
{#each dayNames as day}
<div class="cal-day-header">{day}</div>
{/each}
{#if events.length > 5}
<li class="text-xs text-gray-400">+{events.length - 5} flere</li>
{/if}
</ul>
</div>
<!-- Day cells -->
<div class="cal-grid">
{#each calendarDays as day (day.dateKey)}
{@const dayEvents = eventsByDate.get(day.dateKey) ?? []}
{@const isDropTarget = dragOverDate === day.dateKey}
<div
class="cal-day
{day.isCurrentMonth ? '' : 'cal-day-other'}
{day.isToday ? 'cal-day-today' : ''}
{isDropTarget ? 'cal-day-drop' : ''}"
ondragover={(e: DragEvent) => handleDragOver(e, day.dateKey)}
ondragleave={handleDragLeave}
ondrop={(e: DragEvent) => handleDrop(e, day.dateKey)}
role="gridcell"
>
<!-- Date number + add button -->
<div class="cal-day-num-row">
<span class="cal-day-num {day.isToday ? 'cal-day-num-today' : ''}">
{day.dayOfMonth}
</span>
{#if day.isCurrentMonth && accessToken}
<button
onclick={() => startAddEvent(day.dateKey)}
class="cal-add-btn"
title="Legg til hendelse"
>+</button>
{/if}
</div>
<!-- Events -->
{#each dayEvents as event (event.node.id)}
<div
class="cal-event {eventColorClass(event.node)} {draggedEvent?.node.id === event.node.id ? 'cal-event-dragging' : ''}"
draggable="true"
ondragstart={(e: DragEvent) => handleDragStart(e, event)}
ondragend={handleDragEnd}
title="{event.timeStr ? event.timeStr + ' ' : ''}{event.node.title || 'Uten tittel'}"
>
{#if event.timeStr}
<span class="cal-event-time">{event.timeStr}</span>
{/if}
<span class="cal-event-title">{event.node.title || 'Uten tittel'}</span>
</div>
{/each}
<!-- Inline add form -->
{#if addingToDate === day.dateKey}
<div class="cal-add-form">
<input
type="text"
bind:value={newEventTitle}
onkeydown={handleEventKeydown}
placeholder="Tittel…"
class="cal-add-input"
disabled={isCreating}
/>
<input
type="time"
bind:value={newEventTime}
class="cal-add-input cal-add-time"
/>
<div class="cal-add-actions">
<button
onclick={handleCreateEvent}
disabled={isCreating || !newEventTitle.trim()}
class="cal-add-submit"
>
{isCreating ? '…' : 'Legg til'}
</button>
<button
onclick={() => { addingToDate = null; newEventTitle = ''; newEventTime = ''; }}
class="cal-add-cancel"
>
Avbryt
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
<!-- Event list for current month -->
{@const monthEvents = scheduledEvents.filter(e => {
const d = e.scheduledAt;
return d.getMonth() === viewMonth && d.getFullYear() === viewYear;
})}
{#if monthEvents.length > 0}
<div class="cal-list">
<div class="cal-list-header">Hendelser denne måneden</div>
{#each monthEvents as event (event.node.id)}
<div
class="cal-list-item"
draggable="true"
ondragstart={(e: DragEvent) => handleDragStart(e, event)}
ondragend={handleDragEnd}
>
<span class="cal-list-date">
{event.scheduledAt.getDate()}. {monthNames[event.scheduledAt.getMonth()].slice(0, 3).toLowerCase()}
</span>
{#if event.timeStr}
<span class="cal-list-time">{event.timeStr}</span>
{/if}
<span class="cal-list-title">{event.node.title || 'Uten tittel'}</span>
</div>
{/each}
</div>
{/if}
{/snippet}
</TraitPanel>
{/if}
</div>
<style>
/* ================================================================= */
/* Root — fills BlockShell content area */
/* ================================================================= */
.cal-trait {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
/* ================================================================= */
/* Empty / loading state */
/* ================================================================= */
.cal-empty {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: #9ca3af;
font-size: 13px;
}
/* ================================================================= */
/* Month navigation */
/* ================================================================= */
.cal-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
flex-shrink: 0;
border-bottom: 1px solid #f3f4f6;
}
.cal-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid #e5e7eb;
background: white;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
color: #6b7280;
flex-shrink: 0;
}
.cal-nav-btn:hover {
background: #f9fafb;
color: #374151;
}
.cal-nav-center {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.cal-nav-title {
font-size: 13px;
font-weight: 600;
color: #1f2937;
white-space: nowrap;
}
.cal-today-btn {
padding: 2px 6px;
border: none;
background: #f3f4f6;
border-radius: 4px;
font-size: 10px;
color: #6b7280;
cursor: pointer;
}
.cal-today-btn:hover {
background: #e5e7eb;
}
/* ================================================================= */
/* Calendar grid */
/* ================================================================= */
.cal-grid-wrapper {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
.cal-day-headers {
display: grid;
grid-template-columns: repeat(7, 1fr);
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
position: sticky;
top: 0;
z-index: 1;
}
.cal-day-header {
text-align: center;
font-size: 10px;
font-weight: 600;
color: #9ca3af;
padding: 4px 0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
/* ================================================================= */
/* Day cell */
/* ================================================================= */
.cal-day {
min-height: 56px;
border-bottom: 1px solid #f3f4f6;
border-right: 1px solid #f3f4f6;
padding: 2px;
overflow: hidden;
}
.cal-day-other {
background: #fafbfc;
}
.cal-day-today {
background: #eff6ff;
}
.cal-day-drop {
box-shadow: inset 0 0 0 2px #60a5fa;
background: #eff6ff;
}
.cal-day-num-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2px;
}
.cal-day-num {
font-size: 11px;
font-weight: 500;
color: #6b7280;
line-height: 1;
}
.cal-day-other .cal-day-num {
color: #d1d5db;
}
.cal-day-num-today {
background: #2563eb;
color: white;
border-radius: 9999px;
padding: 1px 4px;
}
.cal-add-btn {
border: none;
background: transparent;
color: #d1d5db;
font-size: 11px;
cursor: pointer;
line-height: 1;
padding: 0 2px;
}
.cal-add-btn:hover {
color: #2563eb;
}
/* ================================================================= */
/* Event chip */
/* ================================================================= */
.cal-event {
display: flex;
align-items: center;
gap: 2px;
margin-top: 1px;
padding: 1px 3px;
border-radius: 3px;
border: 1px solid;
cursor: grab;
overflow: hidden;
font-size: 10px;
line-height: 1.3;
transition: opacity 0.15s;
}
.cal-event:active {
cursor: grabbing;
}
.cal-event-dragging {
opacity: 0.4;
}
.cal-event-default {
background: #fef3c7;
border-color: #fcd34d;
color: #92400e;
}
.cal-event-comm {
background: #dbeafe;
border-color: #93c5fd;
color: #1e40af;
}
.cal-event-media {
background: #ede9fe;
border-color: #c4b5fd;
color: #5b21b6;
}
.cal-event-coll {
background: #d1fae5;
border-color: #6ee7b7;
color: #065f46;
}
.cal-event-time {
font-weight: 600;
flex-shrink: 0;
}
.cal-event-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ================================================================= */
/* Inline add form */
/* ================================================================= */
.cal-add-form {
margin-top: 2px;
padding: 3px;
background: white;
border: 1px solid #bfdbfe;
border-radius: 4px;
}
.cal-add-input {
width: 100%;
padding: 2px 4px;
border: 1px solid #e5e7eb;
border-radius: 3px;
font-size: 10px;
outline: none;
box-sizing: border-box;
}
.cal-add-input:focus {
border-color: #60a5fa;
}
.cal-add-time {
margin-top: 2px;
}
.cal-add-actions {
display: flex;
gap: 3px;
margin-top: 2px;
}
.cal-add-submit {
padding: 1px 6px;
border: none;
border-radius: 3px;
font-size: 10px;
font-weight: 500;
background: #2563eb;
color: white;
cursor: pointer;
}
.cal-add-submit:hover {
background: #1d4ed8;
}
.cal-add-submit:disabled {
opacity: 0.5;
cursor: default;
}
.cal-add-cancel {
padding: 1px 6px;
border: none;
border-radius: 3px;
font-size: 10px;
background: transparent;
color: #6b7280;
cursor: pointer;
}
.cal-add-cancel:hover {
background: #f3f4f6;
}
/* ================================================================= */
/* Event list (below grid) */
/* ================================================================= */
.cal-list {
flex-shrink: 0;
border-top: 1px solid #e5e7eb;
max-height: 140px;
overflow-y: auto;
}
.cal-list-header {
padding: 4px 8px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #9ca3af;
position: sticky;
top: 0;
background: white;
}
.cal-list-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 12px;
cursor: grab;
transition: background 0.1s;
}
.cal-list-item:hover {
background: #f9fafb;
}
.cal-list-item:active {
cursor: grabbing;
}
.cal-list-date {
font-size: 10px;
color: #9ca3af;
flex-shrink: 0;
}
.cal-list-time {
font-size: 10px;
font-weight: 600;
color: #6b7280;
flex-shrink: 0;
}
.cal-list-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #1f2937;
}
/* ================================================================= */
/* Responsive within bounded container */
/* ================================================================= */
/* Small panels: compact everything */
@container (max-width: 360px) {
.cal-nav {
padding: 4px 6px;
}
.cal-nav-title {
font-size: 11px;
}
.cal-nav-btn {
width: 24px;
height: 24px;
font-size: 10px;
}
.cal-day {
min-height: 40px;
padding: 1px;
}
.cal-day-num {
font-size: 9px;
}
.cal-day-header {
font-size: 8px;
padding: 2px 0;
}
.cal-event {
font-size: 8px;
padding: 0 2px;
}
.cal-add-form {
padding: 2px;
}
.cal-list {
max-height: 100px;
}
.cal-list-item {
font-size: 10px;
padding: 3px 6px;
}
}
/* Medium panels: slightly tighter */
@container (max-width: 500px) {
.cal-day {
min-height: 48px;
}
.cal-event {
font-size: 9px;
}
}
/* Mobile viewport fallback */
@media (max-width: 480px) {
.cal-day {
min-height: 40px;
padding: 1px;
}
.cal-day-num {
font-size: 9px;
}
.cal-day-header {
font-size: 8px;
}
.cal-event {
font-size: 8px;
}
.cal-list {
max-height: 100px;
}
}
</style>

View file

@ -344,7 +344,7 @@
{:else if trait === 'rss'}
<RssTrait collection={collectionNode} config={traits[trait]} />
{:else if trait === 'calendar'}
<CalendarTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
<CalendarTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'recording'}
<RecordingTrait collection={collectionNode} config={traits[trait]} {accessToken} />
{:else if trait === 'transcription'}
@ -399,7 +399,7 @@
{:else if trait === 'rss'}
<RssTrait collection={collectionNode} config={traits[trait]} />
{:else if trait === 'calendar'}
<CalendarTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
<CalendarTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'recording'}
<RecordingTrait collection={collectionNode} config={traits[trait]} {accessToken} />
{:else if trait === 'transcription'}

View file

@ -229,8 +229,7 @@ Ref: `docs/features/universell_overfoering.md`, `docs/retninger/arbeidsflaten.md
- [x] 20.4 Transfer service: `innholdstransfer`-modus (ny node + source_material edge) og `lettvekts-triage` (eksisterende node + ny edge/placement). Bestem modus fra verktøy-par. Shift-modifier for override. Ref: `docs/features/universell_overfoering.md` § 1, 3.
- [x] 20.5 Panelrework — Chat: gjør ChatTrait til fullverdig BlockShell-panel med BlockReceiver, fullskjerm-toggle, og responsivt design innenfor begrenset container.
- [x] 20.6 Panelrework — Kanban: gjør KanbanTrait til BlockShell-panel med drag-and-drop aksept fra andre paneler, fullskjerm, responsivt.
- [~] 20.7 Panelrework — Kalender: gjør CalendarTrait til BlockShell-panel med drop-aksept for scheduling, fullskjerm, responsivt.
> Påbegynt: 2026-03-18T08:35
- [x] 20.7 Panelrework — Kalender: gjør CalendarTrait til BlockShell-panel med drop-aksept for scheduling, fullskjerm, responsivt.
- [ ] 20.8 Panelrework — Editor/Artikkelverktøy: gjør artikkelverktøy til BlockShell-panel med source_material mottak fra andre paneler. Ref: `docs/features/artikkelverktoy.md`.
- [ ] 20.9 Panelrework — Studio: gjør StudioTrait til BlockShell-panel med drop-aksept for lydfiler, fullskjerm, responsivt.