311 lines
12 KiB
Plaintext
311 lines
12 KiB
Plaintext
-- allium: 1
|
||
-- bDS Media Editor View
|
||
-- Scope: UI content area — media editing surface
|
||
-- Distilled from: MediaEditor.tsx
|
||
|
||
-- Describes the layout and behaviour of the media editor rendered in
|
||
-- the main content area when a media tab is active.
|
||
|
||
use "./media.allium" as media
|
||
use "./post.allium" as post
|
||
use "./i18n.allium" as i18n
|
||
|
||
-- ─── Media editor ─────────────────────────────────────────────
|
||
|
||
value MediaEditorView {
|
||
media_id: String
|
||
is_image: Boolean
|
||
file_name: String -- originalName, read-only
|
||
mime_type: String -- read-only
|
||
file_size: String -- formatted, read-only
|
||
dimensions: String? -- "W x H" if width/height exist, read-only
|
||
title: String? -- editable text input
|
||
alt_text: String? -- editable text input
|
||
caption: String? -- editable textarea (3 rows)
|
||
tags: String? -- comma-separated text input
|
||
author: String? -- editable text input
|
||
language: String? -- select from supported languages
|
||
translations: List<MediaTranslationItem>
|
||
linked_posts: List<LinkedPostItem>
|
||
}
|
||
|
||
value MediaTranslationItem {
|
||
language: String
|
||
flag_emoji: String
|
||
title: String?
|
||
alt_text: String?
|
||
caption: String?
|
||
}
|
||
|
||
value LinkedPostItem {
|
||
post_id: String
|
||
title: String
|
||
}
|
||
|
||
value PostPickerOverlay {
|
||
search_query: String
|
||
results: List<PostPickerResult>
|
||
overflow_count: Integer? -- shown as "and N more" if > 0
|
||
}
|
||
|
||
surface PostPickerOverlaySurface {
|
||
context overlay: PostPickerOverlay
|
||
|
||
exposes:
|
||
overlay.search_query
|
||
for result in overlay.results:
|
||
result.post_id
|
||
result.title
|
||
overlay.overflow_count when overlay.overflow_count != null
|
||
|
||
provides:
|
||
PostPickerResultClicked(media_id, post_id)
|
||
}
|
||
|
||
value PostPickerResult {
|
||
post_id: String
|
||
title: String
|
||
}
|
||
|
||
config {
|
||
media_post_picker_max_results: Integer = 10
|
||
}
|
||
|
||
surface MediaEditorSurface {
|
||
context editor: MediaEditorView
|
||
|
||
exposes:
|
||
editor.file_name
|
||
editor.mime_type
|
||
editor.file_size
|
||
editor.dimensions when editor.dimensions != null
|
||
editor.is_image
|
||
editor.title
|
||
editor.alt_text
|
||
editor.caption
|
||
editor.tags
|
||
editor.author
|
||
editor.language
|
||
for t in editor.translations:
|
||
t.language
|
||
t.flag_emoji
|
||
t.title
|
||
for lp in editor.linked_posts:
|
||
lp.post_id
|
||
lp.title
|
||
|
||
provides:
|
||
MediaAIImageAnalysisRequested(editor.media_id)
|
||
when editor.is_image
|
||
MediaDetectLanguageRequested(editor.media_id)
|
||
MediaTranslateMetadataRequested(editor.media_id, target_language)
|
||
MediaReplaceFileRequested(editor.media_id)
|
||
MediaSaveRequested(editor.media_id)
|
||
MediaDeleteRequested(editor.media_id)
|
||
MediaLinkToPostRequested(editor.media_id)
|
||
MediaTranslationEditClicked(editor.media_id, language)
|
||
MediaTranslationRefreshClicked(editor.media_id, language)
|
||
MediaUnlinkPostRequested(editor.media_id, post_id)
|
||
MediaDeleteConfirmed(editor.media_id)
|
||
TranslationEditModalSaved(editor.media_id, language, title, alt, caption)
|
||
|
||
@guarantee HeaderLayout
|
||
-- Header bar with media display name.
|
||
-- Actions (right side): Quick Actions dropdown, Replace File button,
|
||
-- Save button, Delete button (danger style).
|
||
|
||
@guarantee QuickActionsDropdown
|
||
-- Dropdown menu in header with three entries:
|
||
-- AI Image Analysis (robot icon) — only shown for image/* MIME types.
|
||
-- Detect Language (magnifier icon).
|
||
-- Translate Metadata (globe icon).
|
||
|
||
@guarantee PreviewArea
|
||
-- Images: rendered at full resolution via the /media-file/:id route
|
||
-- (serves the original file; falls back to the large thumbnail for
|
||
-- formats browsers cannot display inline) with cache-busting
|
||
-- timestamp. Never upscaled beyond natural size. The sidebar keeps
|
||
-- using small thumbnails.
|
||
-- Non-images: SVG file icon placeholder + original filename text.
|
||
|
||
@guarantee MetadataForm
|
||
-- Form fields in order:
|
||
-- File Name (disabled input), MIME Type (disabled input),
|
||
-- Size + Dimensions row (disabled inputs),
|
||
-- Title (text input), Alt Text (text input), Caption (textarea, 3 rows),
|
||
-- Tags (comma-separated text input), Author (text input),
|
||
-- Language (select from supported languages).
|
||
|
||
@guarantee TranslationsSection
|
||
-- Shown only when language is set.
|
||
-- List of existing translations: flag emoji + language name + title.
|
||
-- Per-translation actions: click to edit (opens modal), refresh button.
|
||
-- No delete action; translations are only removed via the scripting
|
||
-- API (script.allium bds.media.delete_translation) or when the
|
||
-- media item itself is deleted.
|
||
-- "No translations" message when list is empty.
|
||
|
||
@guarantee TranslationEditModal
|
||
-- Editing a translation opens a modal dialog ("Edit Translation"), not
|
||
-- an inline form. Hidden language field plus Title, Alt Text, and
|
||
-- Caption (textarea) inputs. Footer: Cancel and Save buttons.
|
||
-- Save persists the translation; Cancel/close discards.
|
||
|
||
@guarantee LinkedPostsSection
|
||
-- "Link to Post" button opens inline post picker overlay.
|
||
-- List of currently linked posts with document icon. Click navigates to post tab.
|
||
-- Per-post unlink button (×).
|
||
-- "Not linked to any posts" message when list is empty.
|
||
|
||
@guarantee PostPickerOverlay
|
||
-- Inline overlay positioned near "Link to Post" button (not a modal).
|
||
-- Search input filtering unlinked posts by title.
|
||
-- Up to config.media_post_picker_max_results results displayed.
|
||
-- "and N more" text when total exceeds limit.
|
||
-- Click result: links media to selected post, closes overlay.
|
||
|
||
@guarantee NoAutoSave
|
||
-- Unlike the post editor, media editor requires explicit Save button.
|
||
}
|
||
|
||
-- ─── Media editor actions ─────────────────────────────────────
|
||
|
||
rule MediaSave {
|
||
when: MediaSaveRequested(media_id)
|
||
let item = media/Media{id: media_id}
|
||
ensures: UpdateMediaRequested(item, editor_field_changes(media_id))
|
||
-- Persists all form fields; the engine update rewrites the
|
||
-- sidecar and FTS entry (media.allium UpdateMedia,
|
||
-- engine_side_effects.allium UpdateMediaSideEffects)
|
||
}
|
||
|
||
rule MediaAIImageAnalysis {
|
||
when: MediaAIImageAnalysisRequested(media_id)
|
||
-- Only available for image/* MIME types (editor.is_image guard
|
||
-- on the provides entry; button hidden for non-images).
|
||
ensures: AISuggestionRequested(entity_type: "media", entity_id: media_id)
|
||
-- Shared flow: action_patterns.allium MediaAISuggestionFlow —
|
||
-- airplane gating, image model over the 448x448 AI thumbnail,
|
||
-- AISuggestionsModal with title/alt/caption, accepted fields
|
||
-- trigger an explicit save.
|
||
}
|
||
|
||
rule MediaDetectLanguage {
|
||
when: MediaDetectLanguageRequested(media_id)
|
||
let item = media/Media{id: media_id}
|
||
requires: active_endpoint_configured
|
||
-- airplane-mode gating: ai.allium AirplaneModeGating
|
||
let detected = ai_detect_language(concat(item.title, item.alt, item.caption))
|
||
-- title model over the concatenated metadata text
|
||
ensures: UpdateMediaRequested(item, language_change(detected))
|
||
-- persisted immediately — no modal, no confirmation; the
|
||
-- engine update rewrites the sidecar
|
||
}
|
||
|
||
rule MediaTranslateMetadata {
|
||
when: MediaTranslateMetadataRequested(media_id, target_language)
|
||
let item = media/Media{id: media_id}
|
||
requires: active_endpoint_configured
|
||
-- target_language comes from the language picker modal (same
|
||
-- pattern as post translate).
|
||
let source_language = item.language
|
||
?? ai_detect_language(concat(item.title, item.alt, item.caption))
|
||
-- step 1: detect the source language first when unset
|
||
-- (auto-persisted, as in MediaDetectLanguage)
|
||
let translated = ai_translate_media_metadata(item, source_language, target_language)
|
||
-- step 2: translate title, alt, caption via the title model
|
||
ensures: UpsertMediaTranslationRequested(item, target_language,
|
||
translated.title, translated.alt, translated.caption)
|
||
-- creates/updates the translation record and writes the
|
||
-- translated sidecar {path}.{lang}.meta (media.allium
|
||
-- UpsertMediaTranslation)
|
||
}
|
||
|
||
rule MediaReplaceFile {
|
||
when: MediaReplaceFileRequested(media_id)
|
||
let item = media/Media{id: media_id}
|
||
let source = native_file_dialog_selection()
|
||
-- native file dialog, no MIME type filter; cancel aborts
|
||
ensures: ReplaceMediaFileRequested(item, source)
|
||
-- copies over the existing media file path; images regenerate
|
||
-- thumbnails synchronously (media.allium ReplaceMediaFile)
|
||
ensures: MediaPreviewRefreshed(media_id)
|
||
-- preview reloads with a cache-busting timestamp query param
|
||
}
|
||
|
||
rule MediaDeleteAction {
|
||
when: MediaDeleteRequested(media_id)
|
||
let item = media/Media{id: media_id}
|
||
ensures: ConfirmDeleteModalOpened(media_id, item.linked_posts)
|
||
-- custom modal (not a native dialog): media display name,
|
||
-- linked post count and list; Cancel + Delete (destructive
|
||
-- red style) — action_patterns.allium
|
||
-- confirmation_assignments: media_delete
|
||
}
|
||
|
||
rule MediaDeleteExecute {
|
||
when: MediaDeleteConfirmed(media_id)
|
||
let item = media/Media{id: media_id}
|
||
ensures: DeleteMediaRequested(item)
|
||
-- file, sidecar, thumbnails, translations, post-media links,
|
||
-- FTS entry: media.allium DeleteMedia +
|
||
-- engine_side_effects.allium DeleteMediaSideEffects
|
||
ensures: closeTab(media_id)
|
||
-- sidebar removes the item reactively
|
||
}
|
||
|
||
rule MediaLinkToPost {
|
||
when: MediaLinkToPostRequested(media_id)
|
||
ensures: PostPickerOverlayOpened(media_id)
|
||
-- inline overlay near the button (not a modal): search input
|
||
-- filters unlinked posts by title, at most
|
||
-- config.media_post_picker_max_results results, "and N more"
|
||
-- when the total exceeds the limit
|
||
}
|
||
|
||
rule MediaLinkToPostSelected {
|
||
when: PostPickerResultClicked(media_id, post_id)
|
||
let item = media/Media{id: media_id}
|
||
ensures: MediaLinkedToPost(item, post/Post{id: post_id})
|
||
-- creates the post-media link and updates sidecar
|
||
-- linkedPostIds; the linked posts list refreshes reactively
|
||
ensures: PostPickerOverlayClosed(media_id)
|
||
}
|
||
|
||
rule MediaUnlinkPost {
|
||
when: MediaUnlinkPostRequested(media_id, post_id)
|
||
let item = media/Media{id: media_id}
|
||
-- No confirmation (action_patterns.allium
|
||
-- confirmation_assignments: media_unlink)
|
||
ensures: MediaUnlinkedFromPost(item, post_id)
|
||
-- removes the post-media link row and updates sidecar
|
||
-- linkedPostIds
|
||
}
|
||
|
||
rule MediaTranslationEdit {
|
||
when: MediaTranslationEditClicked(media_id, language)
|
||
ensures: TranslationEditModalOpened(media_id, language)
|
||
-- "Edit Translation" modal pre-filled with the translation's
|
||
-- title, alt, and caption; Cancel/close discards
|
||
}
|
||
|
||
rule MediaTranslationEditSave {
|
||
when: TranslationEditModalSaved(media_id, language, title, alt, caption)
|
||
let item = media/Media{id: media_id}
|
||
ensures: UpsertMediaTranslationRequested(item, language, title, alt, caption)
|
||
-- persists the DB record + translated sidecar
|
||
-- {path}.{lang}.meta
|
||
}
|
||
|
||
rule MediaTranslationRefresh {
|
||
when: MediaTranslationRefreshClicked(media_id, language)
|
||
let item = media/Media{id: media_id}
|
||
requires: active_endpoint_configured
|
||
let translated = ai_translate_media_metadata(item, item.language, language)
|
||
-- re-translates from the source language via the title model
|
||
ensures: UpsertMediaTranslationRequested(item, language,
|
||
translated.title, translated.alt, translated.caption)
|
||
-- overwrites the existing translation fields and rewrites the
|
||
-- translated sidecar
|
||
}
|