Publiseringsflyt i frontend (oppgave 14.7)

Personlig publiseringsflyt for samlinger med publishing-trait der
require_approval: false. Bruker kan publisere artikler fra mottak
til samlingen, og avpublisere ved å fjerne belongs_to-edge.

Backend:
- Nytt delete_edge-endepunkt i maskinrommet med tilgangskontroll
  og automatisk forside-cache-invalidering ved avpublisering

Frontend:
- PublishDialog: forhåndsvisning, slug-editor, tema-info, bekreftelse
- EditorTrait: publiser/avpubliser-knapper på innholdsnoder i
  publiseringssamlinger, velger for upubliserte artikler
- deleteEdge i API-klienten

Docs:
- Oppdatert api_grensesnitt.md med delete_edge, update_edge, set_slot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 01:33:37 +00:00
parent 703dacd6d0
commit 27d0d8db94
8 changed files with 580 additions and 16 deletions

View file

@ -81,6 +81,20 @@ Tunge spørringer (søk, statistikk, graftraversering) går via maskinrommet →
Krever tilgang: created_by eller owner/admin-edge. Krever tilgang: created_by eller owner/admin-edge.
- Body (JSON): `{ node_id }` - Body (JSON): `{ node_id }`
- Respons: `{ deleted: true }` - Respons: `{ deleted: true }`
- `POST /intentions/update_edge` — Oppdater eksisterende edge (partial update).
Krever tilgang: created_by eller owner/admin-edge til source-noden.
- Body (JSON): `{ edge_id, edge_type?, metadata? }`
- Kun oppgitte felter endres, resten beholdes
- Respons: `{ edge_id: "<uuid>" }`
- `POST /intentions/delete_edge` — Slett en edge. Brukes bl.a. for avpublisering.
Krever tilgang: created_by eller owner/admin-edge til source-noden.
Ved fjerning av belongs_to-edge til publiseringssamling invalideres forside-cache.
- Body (JSON): `{ edge_id }`
- Respons: `{ deleted: true }`
- `POST /intentions/set_slot` — Sett slot-metadata (hero/featured/strøm) på
belongs_to-edge i publiseringssamling. Håndterer hero-erstatning og featured-overflow.
- Body (JSON): `{ edge_id, slot, slot_order?, pinned? }`
- Respons: `{ edge_id, displaced[] }`
### LiveKit / Sanntidslyd (oppgave 11.2) ### LiveKit / Sanntidslyd (oppgave 11.2)
- `POST /intentions/join_communication` — Koble til sanntidslyd i en kommunikasjonsnode. - `POST /intentions/join_communication` — Koble til sanntidslyd i en kommunikasjonsnode.

View file

@ -111,6 +111,25 @@ export function updateEdge(
return post(accessToken, '/intentions/update_edge', data); return post(accessToken, '/intentions/update_edge', data);
} }
// =============================================================================
// Edge-sletting (avpublisering m.m.)
// =============================================================================
export interface DeleteEdgeRequest {
edge_id: string;
}
export interface DeleteEdgeResponse {
deleted: boolean;
}
export function deleteEdge(
accessToken: string,
data: DeleteEdgeRequest
): Promise<DeleteEdgeResponse> {
return post(accessToken, '/intentions/delete_edge', data);
}
// ============================================================================= // =============================================================================
// Board / Kanban // Board / Kanban
// ============================================================================= // =============================================================================

View file

@ -0,0 +1,183 @@
<script lang="ts">
import type { Node } from '$lib/spacetime';
import { createEdge } from '$lib/api';
interface Props {
/** The content node to publish */
node: Node;
/** The target publishing collection */
collection: Node;
/** Publishing trait config */
pubConfig: Record<string, unknown>;
/** Auth token */
accessToken: string;
/** Close callback */
onclose: () => void;
}
let { node, collection, pubConfig, accessToken, onclose }: Props = $props();
const collectionSlug = $derived((pubConfig.slug as string) ?? '');
const theme = $derived((pubConfig.theme as string) ?? 'default');
/** Auto-generate slug from title */
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[æ]/g, 'ae')
.replace(/[ø]/g, 'oe')
.replace(/[å]/g, 'aa')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
let articleSlug = $state(slugify(node.title ?? 'uten-tittel'));
let isPublishing = $state(false);
let error = $state<string | null>(null);
let showPreview = $state(false);
const shortId = $derived(node.id.slice(0, 8));
const publicUrl = $derived(
collectionSlug ? `/api/pub/${collectionSlug}/${shortId}` : ''
);
const previewUrl = $derived(
collectionSlug ? `/api/pub/${collectionSlug}/preview/${theme}` : ''
);
async function handlePublish() {
if (isPublishing) return;
isPublishing = true;
error = null;
try {
await createEdge(accessToken, {
source_id: node.id,
target_id: collection.id,
edge_type: 'belongs_to',
metadata: {}
});
onclose();
} catch (err) {
console.error('Publiseringsfeil:', err);
error = err instanceof Error ? err.message : 'Ukjent feil ved publisering';
} finally {
isPublishing = false;
}
}
</script>
<!-- Backdrop -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}
>
<!-- Dialog -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="w-full max-w-lg rounded-xl bg-white shadow-2xl"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Publiser artikkel"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-4">
<h2 class="text-base font-semibold text-gray-900">Publiser artikkel</h2>
<button
onclick={onclose}
class="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Lukk"
>
&times;
</button>
</div>
<!-- Body -->
<div class="space-y-4 px-5 py-4">
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
{/if}
<!-- Article info -->
<div>
<h3 class="text-sm font-medium text-gray-900">{node.title || 'Uten tittel'}</h3>
{#if node.content}
<p class="mt-1 text-xs text-gray-500">{node.content.slice(0, 200)}{node.content.length > 200 ? '...' : ''}</p>
{/if}
</div>
<!-- Target collection -->
<div>
<dt class="text-xs font-medium text-gray-500">Publiseres i</dt>
<dd class="mt-0.5 text-sm text-gray-900">{collection.title ?? 'Samling'}</dd>
</div>
<!-- Slug editor -->
<div>
<label for="article-slug" class="block text-xs font-medium text-gray-500">
Artikkel-slug
</label>
<div class="mt-1 flex items-center gap-2">
<input
id="article-slug"
type="text"
bind:value={articleSlug}
class="block w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-mono text-gray-900 focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 focus:outline-none"
placeholder="artikkel-slug"
/>
</div>
{#if collectionSlug}
<p class="mt-1 text-xs text-gray-400">
URL: synops.no/pub/{collectionSlug}/{shortId}
</p>
{/if}
</div>
<!-- Theme info -->
<div class="flex items-center gap-4 text-xs text-gray-500">
<span>Tema: <span class="font-medium text-gray-700">{theme}</span></span>
{#if previewUrl}
<button
onclick={() => { showPreview = !showPreview; }}
class="text-emerald-600 hover:underline"
>
{showPreview ? 'Skjul forhåndsvisning' : 'Vis forhåndsvisning'}
</button>
{/if}
</div>
<!-- Preview iframe -->
{#if showPreview && previewUrl}
<div class="overflow-hidden rounded-lg border border-gray-200">
<iframe
src={previewUrl}
title="Tema-forhåndsvisning"
class="h-[300px] w-full border-0"
sandbox="allow-same-origin"
></iframe>
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 border-t border-gray-100 px-5 py-4">
<button
onclick={onclose}
class="rounded-lg px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100"
disabled={isPublishing}
>
Avbryt
</button>
<button
onclick={handlePublish}
class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
disabled={isPublishing}
>
{isPublishing ? 'Publiserer...' : 'Publiser'}
</button>
</div>
</div>
</div>

View file

@ -1,25 +1,69 @@
<script lang="ts"> <script lang="ts">
import type { Node } from '$lib/spacetime'; import type { Node, Edge } from '$lib/spacetime';
import { edgeStore, nodeStore, nodeVisibility } from '$lib/spacetime'; import { edgeStore, nodeStore, nodeVisibility } from '$lib/spacetime';
import { deleteEdge } from '$lib/api';
import TraitPanel from './TraitPanel.svelte'; import TraitPanel from './TraitPanel.svelte';
import PublishDialog from '$lib/components/PublishDialog.svelte';
interface Props { interface Props {
collection: Node; collection: Node;
config: Record<string, unknown>; config: Record<string, unknown>;
userId?: string; userId?: string;
accessToken?: string;
/** Full collection metadata (for reading traits) */
collectionMetadata?: Record<string, unknown>;
} }
let { collection, config, userId }: Props = $props(); let { collection, config, userId, accessToken, collectionMetadata }: Props = $props();
const preset = $derived((config.preset as string) ?? 'longform'); const preset = $derived((config.preset as string) ?? 'longform');
/** Content nodes belonging to this collection */ /** Check if this collection has a personal publishing trait */
const contentNodes = $derived.by(() => { const pubConfig = $derived.by((): Record<string, unknown> | null => {
const nodes: Node[] = []; if (!collectionMetadata) return null;
const traits = collectionMetadata.traits as Record<string, Record<string, unknown>> | undefined;
if (!traits?.publishing) return null;
const pub = traits.publishing;
if (pub.require_approval === true) return null;
return pub;
});
const isPublishingCollection = $derived(pubConfig !== null);
const pubSlug = $derived((pubConfig?.slug as string) ?? '');
/** Content nodes belonging to this collection, with their edges */
interface ContentItem {
node: Node;
edge: Edge;
}
const contentItems = $derived.by((): ContentItem[] => {
const items: ContentItem[] = [];
for (const edge of edgeStore.byTarget(collection.id)) { for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue; if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId); const node = nodeStore.get(edge.sourceId);
if (node && node.nodeKind === 'content' && nodeVisibility(node, userId) !== 'hidden') { if (node && node.nodeKind === 'content' && nodeVisibility(node, userId) !== 'hidden') {
items.push({ node, edge });
}
}
items.sort((a, b) => {
const ta = a.node.createdAt?.microsSinceUnixEpoch ?? 0n;
const tb = b.node.createdAt?.microsSinceUnixEpoch ?? 0n;
return tb > ta ? 1 : tb < ta ? -1 : 0;
});
return items;
});
/** User's content nodes that are NOT already in this collection (for publishing picker) */
const publishableNodes = $derived.by((): Node[] => {
if (!isPublishingCollection || !userId) return [];
const inCollection = new Set(contentItems.map(i => i.node.id));
const nodes: Node[] = [];
// Find content nodes owned by user that aren't in this collection
for (const edge of edgeStore.bySource(userId)) {
if (edge.edgeType !== 'owner') continue;
const node = nodeStore.get(edge.targetId);
if (node && node.nodeKind === 'content' && !inCollection.has(node.id)) {
nodes.push(node); nodes.push(node);
} }
} }
@ -30,6 +74,29 @@
}); });
return nodes; return nodes;
}); });
// State
let showPublishPicker = $state(false);
let publishTarget = $state<Node | null>(null);
let unpublishing = $state<string | null>(null);
let unpublishError = $state<string | null>(null);
let confirmUnpublish = $state<ContentItem | null>(null);
async function handleUnpublish(item: ContentItem) {
if (!accessToken || unpublishing) return;
unpublishing = item.node.id;
unpublishError = null;
try {
await deleteEdge(accessToken, { edge_id: item.edge.id });
confirmUnpublish = null;
} catch (err) {
console.error('Avpubliseringsfeil:', err);
unpublishError = err instanceof Error ? err.message : 'Ukjent feil';
} finally {
unpublishing = null;
}
}
</script> </script>
<TraitPanel name="editor" label="Innhold" icon="📝"> <TraitPanel name="editor" label="Innhold" icon="📝">
@ -37,22 +104,152 @@
<p class="mb-3 text-xs text-gray-500"> <p class="mb-3 text-xs text-gray-500">
Preset: <span class="font-medium">{preset}</span> Preset: <span class="font-medium">{preset}</span>
{#if config.allow_collaborators} {#if config.allow_collaborators}
· Samarbeid aktivert &middot; Samarbeid aktivert
{/if}
{#if isPublishingCollection}
&middot; Publisering aktiv
{/if} {/if}
</p> </p>
{#if contentNodes.length === 0}
{#if unpublishError}
<div class="mb-3 rounded-lg border border-red-200 bg-red-50 p-2 text-xs text-red-700">
{unpublishError}
<button onclick={() => { unpublishError = null; }} class="ml-1 underline">Lukk</button>
</div>
{/if}
{#if contentItems.length === 0}
<p class="text-sm text-gray-400">Ingen innholdsnoder ennå.</p> <p class="text-sm text-gray-400">Ingen innholdsnoder ennå.</p>
{:else} {:else}
<ul class="space-y-2"> <ul class="space-y-2">
{#each contentNodes as node (node.id)} {#each contentItems as item (item.node.id)}
<li class="rounded border border-gray-100 px-3 py-2"> <li class="group rounded border border-gray-100 px-3 py-2">
<h4 class="text-sm font-medium text-gray-900">{node.title || 'Uten tittel'}</h4> <div class="flex items-start justify-between gap-2">
{#if node.content} <div class="min-w-0 flex-1">
<p class="mt-0.5 text-xs text-gray-500 line-clamp-2">{node.content.slice(0, 140)}</p> <h4 class="text-sm font-medium text-gray-900">{item.node.title || 'Uten tittel'}</h4>
{#if item.node.content}
<p class="mt-0.5 text-xs text-gray-500 line-clamp-2">{item.node.content.slice(0, 140)}</p>
{/if} {/if}
{#if isPublishingCollection && pubSlug}
<p class="mt-1 text-xs text-gray-400">
/pub/{pubSlug}/{item.node.id.slice(0, 8)}
</p>
{/if}
</div>
{#if isPublishingCollection && accessToken}
<div class="flex shrink-0 items-center gap-1">
{#if pubSlug}
<a
href="/api/pub/{pubSlug}/{item.node.id.slice(0, 8)}"
target="_blank"
rel="noopener"
class="rounded px-2 py-1 text-xs text-gray-500 opacity-0 group-hover:opacity-100 hover:bg-gray-100 hover:text-gray-700"
title="Se publisert artikkel"
>
Se
</a>
{/if}
<button
onclick={() => { confirmUnpublish = item; }}
class="rounded px-2 py-1 text-xs text-red-500 opacity-0 group-hover:opacity-100 hover:bg-red-50 hover:text-red-700"
title="Fjern fra publisering"
disabled={unpublishing === item.node.id}
>
{unpublishing === item.node.id ? '...' : 'Avpubliser'}
</button>
</div>
{/if}
</div>
</li> </li>
{/each} {/each}
</ul> </ul>
{/if} {/if}
<!-- Publish new article button -->
{#if isPublishingCollection && accessToken}
<div class="mt-4">
<button
onclick={() => { showPublishPicker = !showPublishPicker; }}
class="rounded-lg bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700"
>
Publiser artikkel
</button>
</div>
<!-- Publishable nodes picker -->
{#if showPublishPicker}
<div class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3">
<h4 class="mb-2 text-xs font-semibold text-emerald-800">Velg artikkel å publisere</h4>
{#if publishableNodes.length === 0}
<p class="text-xs text-emerald-600">Ingen upubliserte artikler funnet. Opprett innhold via mottaket først.</p>
{:else}
<ul class="max-h-48 space-y-1 overflow-y-auto">
{#each publishableNodes as pNode (pNode.id)}
<li>
<button
onclick={() => { publishTarget = pNode; showPublishPicker = false; }}
class="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-emerald-100"
>
<span class="font-medium text-gray-900">{pNode.title || 'Uten tittel'}</span>
{#if pNode.content}
<span class="ml-1 text-xs text-gray-500">{pNode.content.slice(0, 60)}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
{/if}
{/snippet} {/snippet}
</TraitPanel> </TraitPanel>
<!-- Publish dialog -->
{#if publishTarget && accessToken && pubConfig}
<PublishDialog
node={publishTarget}
{collection}
pubConfig={pubConfig}
{accessToken}
onclose={() => { publishTarget = null; }}
/>
{/if}
<!-- Unpublish confirmation -->
{#if confirmUnpublish}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onkeydown={(e) => { if (e.key === 'Escape') confirmUnpublish = null; }}
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="w-full max-w-sm rounded-xl bg-white p-5 shadow-2xl"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Bekreft avpublisering"
>
<h3 class="text-base font-semibold text-gray-900">Avpubliser artikkel?</h3>
<p class="mt-2 text-sm text-gray-600">
<strong>{confirmUnpublish.node.title || 'Uten tittel'}</strong> vil bli fjernet fra
publiseringen. Artikkelen slettes ikke, men er ikke lenger offentlig tilgjengelig.
</p>
<div class="mt-4 flex items-center justify-end gap-3">
<button
onclick={() => { confirmUnpublish = null; }}
class="rounded-lg px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-100"
>
Avbryt
</button>
<button
onclick={() => { if (confirmUnpublish) handleUnpublish(confirmUnpublish); }}
class="rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
disabled={unpublishing !== null}
>
{unpublishing ? 'Fjerner...' : 'Avpubliser'}
</button>
</div>
</div>
</div>
{/if}

View file

@ -149,7 +149,7 @@
<div class="space-y-4"> <div class="space-y-4">
{#each renderedTraits as trait (trait)} {#each renderedTraits as trait (trait)}
{#if trait === 'editor'} {#if trait === 'editor'}
<EditorTrait collection={collectionNode} config={traits[trait]} userId={nodeId} /> <EditorTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} collectionMetadata={parsedMetadata} />
{:else if trait === 'chat'} {:else if trait === 'chat'}
<ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} /> <ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
{:else if trait === 'kanban'} {:else if trait === 'kanban'}

View file

@ -948,6 +948,157 @@ pub async fn update_edge(
Ok(Json(UpdateEdgeResponse { edge_id: req.edge_id })) Ok(Json(UpdateEdgeResponse { edge_id: req.edge_id }))
} }
// =============================================================================
// delete_edge
// =============================================================================
#[derive(Deserialize)]
pub struct DeleteEdgeRequest {
/// ID til edgen som skal slettes.
pub edge_id: Uuid,
}
#[derive(Serialize)]
pub struct DeleteEdgeResponse {
pub deleted: bool,
}
#[derive(sqlx::FromRow)]
struct FullEdgeRow {
source_id: Uuid,
target_id: Uuid,
edge_type: String,
}
/// POST /intentions/delete_edge
///
/// Sletter en edge. Brukes bl.a. for avpublisering (fjerner belongs_to-edge).
/// Krever at brukeren har opprettet edgen, eller har owner/admin-edge
/// til source-noden.
pub async fn delete_edge(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<DeleteEdgeRequest>,
) -> Result<Json<DeleteEdgeResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- Tilgangskontroll --
let can_modify = user_can_modify_edge(&state.db, user.node_id, req.edge_id)
.await
.map_err(|e| {
tracing::error!("PG-feil ved tilgangssjekk: {e}");
internal_error("Databasefeil ved tilgangssjekk")
})?;
if !can_modify {
return Err(forbidden("Ingen tilgang til å slette denne edgen"));
}
// Hent edge-info for logging og publiserings-invalidering
let edge_info = sqlx::query_as::<_, FullEdgeRow>(
"SELECT source_id, target_id, edge_type FROM edges WHERE id = $1",
)
.bind(req.edge_id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved henting av edge: {e}");
internal_error("Databasefeil ved henting av edge")
})?
.ok_or_else(|| bad_request(&format!("Edge {} finnes ikke", req.edge_id)))?;
let edge_id_str = req.edge_id.to_string();
// -- Slett fra SpacetimeDB (instant) --
state
.stdb
.delete_edge(&edge_id_str)
.await
.map_err(|e| stdb_error("delete_edge", e))?;
tracing::info!(
edge_id = %req.edge_id,
edge_type = %edge_info.edge_type,
deleted_by = %user.node_id,
"Edge slettet fra STDB"
);
// -- Spawn async PG-sletting + publiserings-invalidering --
spawn_pg_delete_edge(
state.db.clone(),
state.index_cache.clone(),
req.edge_id,
edge_info.source_id,
edge_info.target_id,
edge_info.edge_type,
);
Ok(Json(DeleteEdgeResponse { deleted: true }))
}
/// Spawner en tokio-task som sletter edgen fra PostgreSQL
/// og invaliderer publiserings-cache ved behov.
fn spawn_pg_delete_edge(
db: PgPool,
index_cache: crate::publishing::IndexCache,
edge_id: Uuid,
_source_id: Uuid,
target_id: Uuid,
edge_type: String,
) {
tokio::spawn(async move {
let result = sqlx::query("DELETE FROM edges WHERE id = $1")
.bind(edge_id)
.execute(&db)
.await;
match result {
Ok(_) => {
tracing::info!(edge_id = %edge_id, "Edge slettet fra PostgreSQL");
// Ved fjerning av belongs_to til publiseringssamling: invalider forside-cache
if edge_type == "belongs_to" {
trigger_index_invalidation_if_publishing(&db, &index_cache, target_id).await;
}
}
Err(e) => {
tracing::error!(edge_id = %edge_id, error = %e, "Kunne ikke slette edge fra PostgreSQL");
}
}
});
}
/// Invaliderer forside-cache (dynamisk modus) eller legger render_index-jobb i køen
/// (statisk modus) når en edge fjernes fra en publiseringssamling.
async fn trigger_index_invalidation_if_publishing(
db: &PgPool,
index_cache: &crate::publishing::IndexCache,
collection_id: Uuid,
) {
match crate::publishing::find_publishing_collection_by_id(db, collection_id).await {
Ok(Some(config)) => {
let index_mode = config.index_mode.as_deref().unwrap_or("dynamic");
if index_mode == "static" {
let index_payload = serde_json::json!({
"collection_id": collection_id.to_string(),
});
match crate::jobs::enqueue(db, "render_index", index_payload, Some(collection_id), 4).await {
Ok(job_id) => {
tracing::info!(job_id = %job_id, collection_id = %collection_id, "render_index-jobb lagt i kø etter avpublisering");
}
Err(e) => {
tracing::error!(collection_id = %collection_id, error = %e, "Kunne ikke legge render_index-jobb i kø");
}
}
} else {
crate::publishing::invalidate_index_cache(index_cache, collection_id).await;
}
}
Ok(None) => {}
Err(e) => {
tracing::error!(collection_id = %collection_id, error = %e, "Feil ved sjekk av publiseringssamling for cache-invalidering");
}
}
}
// ============================================================================= // =============================================================================
// set_slot — Redaksjonell slot-håndtering for publiseringssamlinger // set_slot — Redaksjonell slot-håndtering for publiseringssamlinger
// ============================================================================= // =============================================================================

View file

@ -151,6 +151,7 @@ async fn main() {
.route("/intentions/update_node", post(intentions::update_node)) .route("/intentions/update_node", post(intentions::update_node))
.route("/intentions/delete_node", post(intentions::delete_node)) .route("/intentions/delete_node", post(intentions::delete_node))
.route("/intentions/update_edge", post(intentions::update_edge)) .route("/intentions/update_edge", post(intentions::update_edge))
.route("/intentions/delete_edge", post(intentions::delete_edge))
.route("/intentions/set_slot", post(intentions::set_slot)) .route("/intentions/set_slot", post(intentions::set_slot))
.route("/intentions/create_communication", post(intentions::create_communication)) .route("/intentions/create_communication", post(intentions::create_communication))
.route("/intentions/upload_media", post(intentions::upload_media)) .route("/intentions/upload_media", post(intentions::upload_media))

View file

@ -148,8 +148,7 @@ Uavhengige faser kan fortsatt plukkes.
- [x] 14.4 Caddy-ruting for synops.no/pub: Caddy reverse-proxyer til maskinrommet som gjør slug→hash-oppslag og streamer CAS-fil. `Cache-Control: immutable` for artikler. Kategori/arkiv/søk serveres dynamisk av maskinrommet med kortere cache-TTL. - [x] 14.4 Caddy-ruting for synops.no/pub: Caddy reverse-proxyer til maskinrommet som gjør slug→hash-oppslag og streamer CAS-fil. `Cache-Control: immutable` for artikler. Kategori/arkiv/søk serveres dynamisk av maskinrommet med kortere cache-TTL.
- [x] 14.5 Slot-håndtering i maskinrommet: `slot` og `slot_order` i `belongs_to`-edge metadata. Ved ny hero → gammel hero flyttes til strøm. Ved featured over `featured_max` → FIFO tilbake til strøm. `pinned`-flagg forhindrer automatisk fjerning. - [x] 14.5 Slot-håndtering i maskinrommet: `slot` og `slot_order` i `belongs_to`-edge metadata. Ved ny hero → gammel hero flyttes til strøm. Ved featured over `featured_max` → FIFO tilbake til strøm. `pinned`-flagg forhindrer automatisk fjerning.
- [x] 14.6 Forside-admin i frontend: visuell editor for hero/featured/strøm. Drag-and-drop mellom plasser. Pin-knapp. Forhåndsvisning. Oppdaterer edge-metadata via maskinrommet. - [x] 14.6 Forside-admin i frontend: visuell editor for hero/featured/strøm. Drag-and-drop mellom plasser. Pin-knapp. Forhåndsvisning. Oppdaterer edge-metadata via maskinrommet.
- [~] 14.7 Publiseringsflyt i frontend (personlig): publiseringsknapp på noder i samlinger med `publishing`-trait der `require_approval: false`. Forhåndsvisning, slug-editor, bekreftelse. Avpublisering ved fjerning av edge. - [x] 14.7 Publiseringsflyt i frontend (personlig): publiseringsknapp på noder i samlinger med `publishing`-trait der `require_approval: false`. Forhåndsvisning, slug-editor, bekreftelse. Avpublisering ved fjerning av edge.
> Påbegynt: 2026-03-18T01:25
- [ ] 14.8 RSS/Atom-feed: samling med `rss`-trait genererer feed automatisk ved publisering/avpublisering. `synops.no/pub/{slug}/feed.xml`. Maks `rss_max_items` (default 50). - [ ] 14.8 RSS/Atom-feed: samling med `rss`-trait genererer feed automatisk ved publisering/avpublisering. `synops.no/pub/{slug}/feed.xml`. Maks `rss_max_items` (default 50).
- [ ] 14.9 Custom domains: bruker registrerer domene i `publishing`-trait. Maskinrommet validerer DNS, Caddy on-demand TLS med validerings-callback. Re-rendring med riktig canonical URL. - [ ] 14.9 Custom domains: bruker registrerer domene i `publishing`-trait. Maskinrommet validerer DNS, Caddy on-demand TLS med validerings-callback. Re-rendring med riktig canonical URL.
- [ ] 14.10 Redaksjonell innsending: `submitted_to`-edge med status-metadata (`pending`, `in_review`, `revision_requested`, `rejected`, `approved`). Maskinrommet validerer at kun roller i `submission_roles` kan opprette `submitted_to`, og kun owner/admin kan endre status eller opprette `belongs_to`. Ref: `docs/concepts/publisering.md` § "Innsending". - [ ] 14.10 Redaksjonell innsending: `submitted_to`-edge med status-metadata (`pending`, `in_review`, `revision_requested`, `rejected`, `approved`). Maskinrommet validerer at kun roller i `submission_roles` kan opprette `submitted_to`, og kun owner/admin kan endre status eller opprette `belongs_to`. Ref: `docs/concepts/publisering.md` § "Innsending".