Validering av fase 22 (SpacetimeDB-migrering) bekrefter:
1. WebSocket-sanntid fungerer:
- maskinrommet lytter på PG NOTIFY-kanaler (node_changed, edge_changed,
access_changed, mixer_channel_changed)
- Enrichment av events med fulle rader fra PG
- Broadcast via tokio::broadcast til WebSocket-klienter
- Tilgangskontroll filtrerer events per bruker
- Frontend kobler til /ws med JWT, mottar initial_sync + inkrementelle events
2. PG LISTEN/NOTIFY-triggere verifisert i database:
- 4 notify-funksjoner: notify_node_change, notify_edge_change,
notify_access_change, notify_mixer_channel_change
- 4 triggere: nodes_notify, edges_notify, node_access_notify,
mixer_channels_notify
3. Ingen STDB-rester i aktiv kode/konfig:
- maskinrommet/src/: rent
- Cargo.toml: ingen spacetimedb-avhengigheter
- docker-compose.yml: ingen spacetimedb-tjeneste
- Caddyfile: ingen spacetimedb-proxy
- Eneste funn: frontend/src/lib/spacetime/ katalognavn —
omdøpt til frontend/src/lib/realtime/ (32 filer oppdatert)
- Historiske referanser i docs/arkiv og scripts/synops.md er OK
923 lines
22 KiB
Svelte
923 lines
22 KiB
Svelte
<script lang="ts">
|
|
import type { Node, Edge } from '$lib/realtime';
|
|
import { edgeStore, nodeStore, nodeVisibility, connectionState } from '$lib/realtime';
|
|
import { createNode, createEdge, updateEdge } from '$lib/api';
|
|
import { setDragPayload, checkCalendarCompat, type DragPayload } from '$lib/transfer';
|
|
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
|
|
|
|
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, accessToken, onReceiveDrop }: Props = $props();
|
|
|
|
const connected = $derived(connectionState.current === 'connected');
|
|
|
|
/**
|
|
* BlockReceiver implementation for Calendar.
|
|
* Accepts communication and content nodes as new events/scheduled items.
|
|
* Kanban cards get a scheduled edge.
|
|
* Ref: docs/retninger/arbeidsflaten.md § Kompatibilitetsmatrise
|
|
*/
|
|
export const receiver: BlockReceiver = {
|
|
canReceive(payload: DragPayload) {
|
|
return checkCalendarCompat(payload);
|
|
},
|
|
receive(payload: DragPayload) {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const mode = payload.sourcePanel === 'kanban' ? 'lettvekts-triage' as const : 'innholdstransfer' as const;
|
|
const intent: PlacementIntent = {
|
|
mode,
|
|
contextId: collection?.id ?? '',
|
|
contextType: 'calendar',
|
|
position: { date: today, all_day: true },
|
|
};
|
|
onReceiveDrop?.(payload, intent);
|
|
return intent;
|
|
}
|
|
};
|
|
|
|
// =========================================================================
|
|
// 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 WebSocket store
|
|
// =========================================================================
|
|
|
|
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 || nodeVisibility(node, userId) === 'hidden') continue;
|
|
|
|
let at: string | undefined;
|
|
try {
|
|
const meta = JSON.parse(edge.metadata ?? '{}');
|
|
at = meta.at;
|
|
} catch { /* ignore */ }
|
|
|
|
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 });
|
|
}
|
|
|
|
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>
|
|
|
|
<!--
|
|
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">←</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">→</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}
|
|
</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}
|
|
{/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>
|