Compare commits
No commits in common. "2ba830c7b486ae3063b32f6c95137c7b33e309ec" and "ea0671933d20f3bad56ad543d9e06103db6468c8" have entirely different histories.
2ba830c7b4
...
ea0671933d
4 changed files with 1 additions and 433 deletions
|
|
@ -1,66 +0,0 @@
|
||||||
# Feature: Dagbok (Privat journal)
|
|
||||||
**Filsti:** `docs/features/dagbok.md`
|
|
||||||
|
|
||||||
## 1. Konsept
|
|
||||||
En personlig dagbok-visning som samler brukerens private noder — innhold
|
|
||||||
som ikke er delt med andre via edges. Fungerer som en kronologisk logg
|
|
||||||
over tanker, notater og idéer som kun er synlige for eieren.
|
|
||||||
|
|
||||||
## 2. Status
|
|
||||||
**Implementert med nodes+edges (mars 2026).** Sanntid via SpacetimeDB.
|
|
||||||
|
|
||||||
### Implementert
|
|
||||||
- Frontend: `/diary` route med dagbok-visning
|
|
||||||
- Filtrering: viser kun noder som er opprettet av brukeren og ikke har
|
|
||||||
delte edges (ingen non-system edges til andre brukere/noder)
|
|
||||||
- Ekskluderte node-typer: `communication`, `agent`, `person`, `team`
|
|
||||||
- Gruppering etter dato med norske datoetiketter ("I dag", "I går", ellers fullt format)
|
|
||||||
- Kronologisk sortering (nyeste først)
|
|
||||||
- Tidsstempel per innlegg
|
|
||||||
- Inline oppretting av nye dagbokinnlegg (tittel + innhold)
|
|
||||||
- Nye innlegg får `visibility: 'hidden'` og `owner`-edge fra bruker
|
|
||||||
- Dagbok-lenke med tellebadge i mottak-siden
|
|
||||||
- Responsivt design (max-w-3xl, mobilklar)
|
|
||||||
|
|
||||||
### Gjenstår
|
|
||||||
- Redigeringsmodus for eksisterende innlegg
|
|
||||||
- Rik tekst-editor (gjenbruk NodeEditor-komponenten)
|
|
||||||
- Sletting av innlegg
|
|
||||||
- Søk/filtrering i dagboken
|
|
||||||
- Eksport-funksjon
|
|
||||||
|
|
||||||
## 3. Datamodell
|
|
||||||
|
|
||||||
Dagboken bruker ingen egne tabeller eller edge-typer. Den er en
|
|
||||||
**visning** (query) over eksisterende noder og edges.
|
|
||||||
|
|
||||||
### Hva er et dagbokinnlegg?
|
|
||||||
En node som oppfyller alle tre kriterier:
|
|
||||||
1. `created_by = <brukerens node-ID>`
|
|
||||||
2. `node_kind` er ikke `communication`, `agent`, `person` eller `team`
|
|
||||||
3. Ingen non-system edges der den andre enden er en annen bruker/node
|
|
||||||
(kun `owner`-edge fra bruker, system-edges, og selv-refererende edges er tillatt)
|
|
||||||
|
|
||||||
### Oppretting
|
|
||||||
```
|
|
||||||
POST /intentions/create_node
|
|
||||||
{ node_kind: "content", title: "...", content: "...", visibility: "hidden" }
|
|
||||||
POST /intentions/create_edge
|
|
||||||
{ source_id: <bruker>, target_id: <ny_node>, edge_type: "owner" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Frontend
|
|
||||||
|
|
||||||
### Route
|
|
||||||
`/diary` → `frontend/src/routes/diary/+page.svelte`
|
|
||||||
|
|
||||||
### Datakilde
|
|
||||||
SpacetimeDB sanntidsabonnement via `nodeStore` og `edgeStore`.
|
|
||||||
Ingen backend-query — all filtrering skjer i frontend basert på
|
|
||||||
SpacetimeDB-data som allerede er lastet.
|
|
||||||
|
|
||||||
### UI-struktur
|
|
||||||
- Header med tilbake-lenke til mottak og innlegg-teller
|
|
||||||
- Ny-innlegg-knapp (utvides til skjema med tittel + tekstfelt)
|
|
||||||
- Innlegg gruppert per dato, sortert nyeste først
|
|
||||||
- Hvert innlegg viser tidsstempel, tittel, og innholdsutdrag
|
|
||||||
|
|
@ -135,31 +135,6 @@
|
||||||
return edgeStore.byType('scheduled').length;
|
return edgeStore.byType('scheduled').length;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Count private diary entries for badge display */
|
|
||||||
const diaryCount = $derived.by(() => {
|
|
||||||
if (!connected || !nodeId) return 0;
|
|
||||||
let count = 0;
|
|
||||||
for (const node of nodeStore.all) {
|
|
||||||
if (node.createdBy !== nodeId) continue;
|
|
||||||
if (node.nodeKind === 'communication' || node.nodeKind === 'agent' ||
|
|
||||||
node.nodeKind === 'person' || node.nodeKind === 'team') continue;
|
|
||||||
// Check for shared edges (same logic as diary page)
|
|
||||||
let shared = false;
|
|
||||||
for (const edge of edgeStore.bySource(node.id)) {
|
|
||||||
if (edge.system || edge.targetId === node.id || edge.targetId === nodeId) continue;
|
|
||||||
shared = true; break;
|
|
||||||
}
|
|
||||||
if (!shared) {
|
|
||||||
for (const edge of edgeStore.byTarget(node.id)) {
|
|
||||||
if (edge.system || edge.sourceId === node.id || edge.sourceId === nodeId) continue;
|
|
||||||
shared = true; break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!shared) count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
});
|
|
||||||
|
|
||||||
let isCreatingBoard = $state(false);
|
let isCreatingBoard = $state(false);
|
||||||
|
|
||||||
/** Create a new kanban board */
|
/** Create a new kanban board */
|
||||||
|
|
@ -310,12 +285,6 @@
|
||||||
<h2 class="text-xl font-semibold text-gray-800">Mottak</h2>
|
<h2 class="text-xl font-semibold text-gray-800">Mottak</h2>
|
||||||
{#if connected && accessToken}
|
{#if connected && accessToken}
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a
|
|
||||||
href="/diary"
|
|
||||||
class="rounded-lg bg-violet-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-violet-700"
|
|
||||||
>
|
|
||||||
Dagbok{#if diaryCount > 0} ({diaryCount}){/if}
|
|
||||||
</a>
|
|
||||||
<a
|
<a
|
||||||
href="/calendar"
|
href="/calendar"
|
||||||
class="rounded-lg bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700"
|
class="rounded-lg bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700"
|
||||||
|
|
|
||||||
|
|
@ -1,335 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import { connectionState, nodeStore, edgeStore, nodeVisibility } from '$lib/spacetime';
|
|
||||||
import type { Node } from '$lib/spacetime';
|
|
||||||
import { createNode, createEdge } from '$lib/api';
|
|
||||||
|
|
||||||
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
|
||||||
const nodeId = $derived(session?.nodeId as string | undefined);
|
|
||||||
const accessToken = $derived(session?.accessToken as string | undefined);
|
|
||||||
const connected = $derived(connectionState.current === 'connected');
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Diary entries: private nodes with no shared edges, sorted by time
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A diary entry is a node that:
|
|
||||||
* 1. Was created by the current user
|
|
||||||
* 2. Has no non-system edges connecting it to other users/nodes
|
|
||||||
* (only owner edge from user → node is allowed)
|
|
||||||
* 3. Is not a special kind (communication, agent)
|
|
||||||
*/
|
|
||||||
const diaryEntries = $derived.by((): Node[] => {
|
|
||||||
if (!connected || !nodeId) return [];
|
|
||||||
|
|
||||||
const entries: Node[] = [];
|
|
||||||
|
|
||||||
for (const node of nodeStore.all) {
|
|
||||||
// Must be created by current user
|
|
||||||
if (node.createdBy !== nodeId) continue;
|
|
||||||
|
|
||||||
// Skip special node kinds that aren't diary material
|
|
||||||
if (node.nodeKind === 'communication' || node.nodeKind === 'agent' ||
|
|
||||||
node.nodeKind === 'person' || node.nodeKind === 'team') continue;
|
|
||||||
|
|
||||||
// Check if this node is "private" — no shared edges
|
|
||||||
// Allowed edges: owner from user, system edges, self-referential edges
|
|
||||||
if (hasSharedEdges(node.id)) continue;
|
|
||||||
|
|
||||||
entries.push(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by created_at descending (newest first)
|
|
||||||
entries.sort((a, b) => {
|
|
||||||
const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n;
|
|
||||||
const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n;
|
|
||||||
return tb > ta ? 1 : tb < ta ? -1 : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a node has edges that "share" it with other users/nodes.
|
|
||||||
* Returns true if the node has non-private edges.
|
|
||||||
*
|
|
||||||
* Private means: only edges where the other end is the current user,
|
|
||||||
* the node itself, or system edges.
|
|
||||||
*/
|
|
||||||
function hasSharedEdges(targetNodeId: string): boolean {
|
|
||||||
if (!nodeId) return false;
|
|
||||||
|
|
||||||
// Check edges FROM this node
|
|
||||||
for (const edge of edgeStore.bySource(targetNodeId)) {
|
|
||||||
if (edge.system) continue;
|
|
||||||
// Self-referential edge (e.g. scheduled edge pointing to itself)
|
|
||||||
if (edge.targetId === targetNodeId) continue;
|
|
||||||
// Edge to the owner (user)
|
|
||||||
if (edge.targetId === nodeId) continue;
|
|
||||||
// Any other edge means it's shared
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check edges TO this node
|
|
||||||
for (const edge of edgeStore.byTarget(targetNodeId)) {
|
|
||||||
if (edge.system) continue;
|
|
||||||
// Self-referential
|
|
||||||
if (edge.sourceId === targetNodeId) continue;
|
|
||||||
// Edge from the owner (user), e.g. owner edge
|
|
||||||
if (edge.sourceId === nodeId) continue;
|
|
||||||
// Any other edge means it's shared
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Date grouping
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
interface DiaryGroup {
|
|
||||||
dateLabel: string;
|
|
||||||
dateKey: string;
|
|
||||||
entries: Node[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const monthNames = [
|
|
||||||
'januar', 'februar', 'mars', 'april', 'mai', 'juni',
|
|
||||||
'juli', 'august', 'september', 'oktober', 'november', 'desember'
|
|
||||||
];
|
|
||||||
const dayNames = ['søndag', 'mandag', 'tirsdag', 'onsdag', 'torsdag', 'fredag', 'lørdag'];
|
|
||||||
|
|
||||||
function nodeDate(node: Node): Date {
|
|
||||||
const micros = node.createdAt?.microsSinceUnixEpoch ?? 0n;
|
|
||||||
return new Date(Number(micros / 1000n));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateKey(d: Date): string {
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateLabel(d: Date): string {
|
|
||||||
const today = new Date();
|
|
||||||
const todayKey = formatDateKey(today);
|
|
||||||
const dateKey = formatDateKey(d);
|
|
||||||
|
|
||||||
if (dateKey === todayKey) return 'I dag';
|
|
||||||
|
|
||||||
const yesterday = new Date(today);
|
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
|
||||||
if (dateKey === formatDateKey(yesterday)) return 'I går';
|
|
||||||
|
|
||||||
return `${dayNames[d.getDay()]} ${d.getDate()}. ${monthNames[d.getMonth()]} ${d.getFullYear()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Group diary entries by date */
|
|
||||||
const groupedEntries = $derived.by((): DiaryGroup[] => {
|
|
||||||
const groups = new Map<string, { label: string; entries: Node[] }>();
|
|
||||||
|
|
||||||
for (const node of diaryEntries) {
|
|
||||||
const d = nodeDate(node);
|
|
||||||
const key = formatDateKey(d);
|
|
||||||
const existing = groups.get(key);
|
|
||||||
if (existing) {
|
|
||||||
existing.entries.push(node);
|
|
||||||
} else {
|
|
||||||
groups.set(key, { label: formatDateLabel(d), entries: [node] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...groups.entries()].map(([dateKey, { label, entries }]) => ({
|
|
||||||
dateLabel: label,
|
|
||||||
dateKey,
|
|
||||||
entries
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Create new diary entry
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
let newTitle = $state('');
|
|
||||||
let newContent = $state('');
|
|
||||||
let isCreating = $state(false);
|
|
||||||
let showForm = $state(false);
|
|
||||||
|
|
||||||
async function handleCreate() {
|
|
||||||
if (!accessToken || !nodeId || !newTitle.trim() || isCreating) return;
|
|
||||||
|
|
||||||
isCreating = true;
|
|
||||||
try {
|
|
||||||
const { node_id } = await createNode(accessToken, {
|
|
||||||
node_kind: 'content',
|
|
||||||
title: newTitle.trim(),
|
|
||||||
content: newContent.trim() || undefined,
|
|
||||||
visibility: 'hidden'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create owner edge
|
|
||||||
await createEdge(accessToken, {
|
|
||||||
source_id: nodeId,
|
|
||||||
target_id: node_id,
|
|
||||||
edge_type: 'owner'
|
|
||||||
});
|
|
||||||
|
|
||||||
newTitle = '';
|
|
||||||
newContent = '';
|
|
||||||
showForm = false;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Feil ved oppretting av dagbokinnlegg:', err);
|
|
||||||
} finally {
|
|
||||||
isCreating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
showForm = false;
|
|
||||||
newTitle = '';
|
|
||||||
newContent = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Time formatting
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
function formatTime(node: Node): string {
|
|
||||||
const d = nodeDate(node);
|
|
||||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Truncate content to a short excerpt */
|
|
||||||
function excerpt(content: string, maxLen = 200): string {
|
|
||||||
if (!content) return '';
|
|
||||||
if (content.length <= maxLen) return content;
|
|
||||||
return content.slice(0, maxLen).trimEnd() + '…';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Kind label in Norwegian */
|
|
||||||
function kindLabel(kind: string): string {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
content: 'Innhold',
|
|
||||||
collection: 'Samling',
|
|
||||||
topic: 'Tema',
|
|
||||||
media: 'Media'
|
|
||||||
};
|
|
||||||
return labels[kind] ?? kind;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="min-h-screen bg-gray-50">
|
|
||||||
<!-- Header -->
|
|
||||||
<header class="border-b border-gray-200 bg-white">
|
|
||||||
<div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">← Mottak</a>
|
|
||||||
<h1 class="text-lg font-semibold text-gray-900">Dagbok</h1>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
{#if connected}
|
|
||||||
<span class="text-xs text-green-600">Tilkoblet</span>
|
|
||||||
{:else}
|
|
||||||
<span class="text-xs text-gray-400">{connectionState.current}</span>
|
|
||||||
{/if}
|
|
||||||
<span class="text-xs text-gray-400">{diaryEntries.length} innlegg</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="mx-auto max-w-3xl px-4 py-6">
|
|
||||||
{#if !connected}
|
|
||||||
<p class="text-sm text-gray-400">Venter på tilkobling…</p>
|
|
||||||
{:else}
|
|
||||||
<!-- New entry button / form -->
|
|
||||||
{#if accessToken && nodeId}
|
|
||||||
{#if !showForm}
|
|
||||||
<button
|
|
||||||
onclick={() => showForm = true}
|
|
||||||
class="mb-6 w-full rounded-lg border-2 border-dashed border-gray-300 bg-white p-4 text-sm text-gray-500 hover:border-gray-400 hover:text-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
Nytt dagbokinnlegg…
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-4 shadow-sm" onkeydown={handleKeydown}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={newTitle}
|
|
||||||
placeholder="Tittel…"
|
|
||||||
class="w-full rounded border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none"
|
|
||||||
disabled={isCreating}
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
bind:value={newContent}
|
|
||||||
placeholder="Skriv her…"
|
|
||||||
rows={4}
|
|
||||||
class="mt-2 w-full rounded border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none resize-y"
|
|
||||||
disabled={isCreating}
|
|
||||||
></textarea>
|
|
||||||
<div class="mt-3 flex gap-2">
|
|
||||||
<button
|
|
||||||
onclick={handleCreate}
|
|
||||||
disabled={isCreating || !newTitle.trim()}
|
|
||||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isCreating ? 'Lagrer…' : 'Lagre'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => { showForm = false; newTitle = ''; newContent = ''; }}
|
|
||||||
class="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
Avbryt
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Diary entries grouped by date -->
|
|
||||||
{#if diaryEntries.length === 0}
|
|
||||||
<div class="text-center py-12">
|
|
||||||
<p class="text-gray-400 text-sm">Ingen dagbokinnlegg ennå.</p>
|
|
||||||
<p class="text-gray-300 text-xs mt-1">Private noder uten delte edges vises her.</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-6">
|
|
||||||
{#each groupedEntries as group (group.dateKey)}
|
|
||||||
<div>
|
|
||||||
<h2 class="mb-2 text-sm font-semibold text-gray-500 uppercase tracking-wide">
|
|
||||||
{group.dateLabel}
|
|
||||||
</h2>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
{#each group.entries as node (node.id)}
|
|
||||||
<li class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="shrink-0 text-xs text-gray-400">{formatTime(node)}</span>
|
|
||||||
<h3 class="font-medium text-gray-900 truncate">
|
|
||||||
{node.title || 'Uten tittel'}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
{#if node.content}
|
|
||||||
<p class="mt-1 text-sm text-gray-500 whitespace-pre-line">
|
|
||||||
{excerpt(node.content)}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if node.nodeKind !== 'content'}
|
|
||||||
<span class="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
|
|
||||||
{kindLabel(node.nodeKind)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
2
tasks.md
2
tasks.md
|
|
@ -111,7 +111,7 @@ Uavhengige faser kan fortsatt plukkes.
|
||||||
|
|
||||||
- [x] 9.1 Kanban-visning: noder med board-edge, gruppert på status-edge. Drag-and-drop for statusendring.
|
- [x] 9.1 Kanban-visning: noder med board-edge, gruppert på status-edge. Drag-and-drop for statusendring.
|
||||||
- [x] 9.2 Kalender-visning: noder med `scheduled`-edge, på tidslinje.
|
- [x] 9.2 Kalender-visning: noder med `scheduled`-edge, på tidslinje.
|
||||||
- [x] 9.3 Dagbok-visning: private noder (ingen delte edges), sortert på tid.
|
- [ ] 9.3 Dagbok-visning: private noder (ingen delte edges), sortert på tid.
|
||||||
- [ ] 9.4 Kunnskapsgraf: topic-noder, `mentions`-edges. Visuell graf-visning.
|
- [ ] 9.4 Kunnskapsgraf: topic-noder, `mentions`-edges. Visuell graf-visning.
|
||||||
|
|
||||||
## Fase 10: AI og beriking
|
## Fase 10: AI og beriking
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue