created_at swagger

This commit is contained in:
2025-10-30 23:03:57 +01:00
parent 5386724e0d
commit 4a3cb63263
15 changed files with 217 additions and 142 deletions
@@ -65,7 +65,7 @@ CREATE TABLE documents (
original_name VARCHAR(255) NOT NULL, original_name VARCHAR(255) NOT NULL,
content_type VARCHAR(100), content_type VARCHAR(100),
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL, folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb, metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
+4
View File
@@ -56,6 +56,7 @@ impl PreparedPasskey {
pub struct RegistrationChallengeResponse { pub struct RegistrationChallengeResponse {
pub challenge_id: Uuid, pub challenge_id: Uuid,
#[serde(flatten)] #[serde(flatten)]
#[schema(value_type = Object)]
pub challenge: CreationChallengeResponse, pub challenge: CreationChallengeResponse,
} }
@@ -64,6 +65,7 @@ pub struct RegistrationChallengeResponse {
pub struct AuthenticationChallengeResponse { pub struct AuthenticationChallengeResponse {
pub challenge_id: Uuid, pub challenge_id: Uuid,
#[serde(flatten)] #[serde(flatten)]
#[schema(value_type = Object)]
pub challenge: RequestChallengeResponse, pub challenge: RequestChallengeResponse,
} }
@@ -651,6 +653,7 @@ impl From<UserPasskey> for PasskeySummary {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationFinishPayload { pub struct PasskeyRegistrationFinishPayload {
pub challenge_id: Uuid, pub challenge_id: Uuid,
#[schema(value_type = Object)]
pub credential: RegisterPublicKeyCredential, pub credential: RegisterPublicKeyCredential,
#[serde(default)] #[serde(default)]
pub nickname: Option<String>, pub nickname: Option<String>,
@@ -666,5 +669,6 @@ pub struct PasskeyLoginStartPayload {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PasskeyLoginFinishPayload { pub struct PasskeyLoginFinishPayload {
pub challenge_id: Uuid, pub challenge_id: Uuid,
#[schema(value_type = Object)]
pub credential: PublicKeyCredential, pub credential: PublicKeyCredential,
} }
+1 -1
View File
@@ -240,7 +240,7 @@ pub struct Document {
pub original_name: String, pub original_name: String,
pub content_type: Option<String>, pub content_type: Option<String>,
pub folder_id: Option<Uuid>, pub folder_id: Option<Uuid>,
pub uploaded_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>, pub deleted_at: Option<NaiveDateTime>,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
+3 -2
View File
@@ -721,6 +721,7 @@ pub mod schemas {
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct SignupFinishRequest { pub struct SignupFinishRequest {
pub signup_token: String, pub signup_token: String,
#[schema(value_type = Object)]
pub credential: RegisterPublicKeyCredential, pub credential: RegisterPublicKeyCredential,
#[schema(nullable)] #[schema(nullable)]
pub nickname: Option<String>, pub nickname: Option<String>,
@@ -867,7 +868,7 @@ pub mod schemas {
pub content_type: Option<String>, pub content_type: Option<String>,
#[schema(nullable)] #[schema(nullable)]
pub folder_id: Option<Uuid>, pub folder_id: Option<Uuid>,
pub uploaded_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
#[schema(nullable)] #[schema(nullable)]
pub deleted_at: Option<String>, pub deleted_at: Option<String>,
@@ -1038,7 +1039,7 @@ pub mod schemas {
#[schema(nullable)] #[schema(nullable)]
pub version_number: Option<i32>, pub version_number: Option<i32>,
#[schema(nullable)] #[schema(nullable)]
pub uploaded_at: Option<String>, pub created_at: Option<String>,
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
+7 -7
View File
@@ -108,7 +108,7 @@ pub struct DocumentCheckResponse {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub version_number: Option<i32>, pub version_number: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub uploaded_at: Option<String>, pub created_at: Option<String>,
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
@@ -136,7 +136,7 @@ pub struct DocumentResponse {
pub original_name: String, pub original_name: String,
pub content_type: Option<String>, pub content_type: Option<String>,
pub folder_id: Option<Uuid>, pub folder_id: Option<Uuid>,
pub uploaded_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
pub deleted_at: Option<String>, pub deleted_at: Option<String>,
pub issued_at: Option<String>, pub issued_at: Option<String>,
@@ -554,14 +554,14 @@ pub async fn list_documents(
if !by_id.is_empty() { if !by_id.is_empty() {
let mut remaining: Vec<Document> = by_id.into_values().collect(); let mut remaining: Vec<Document> = by_id.into_values().collect();
remaining.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at)); remaining.sort_by(|a, b| b.created_at.cmp(&a.created_at));
ordered.extend(remaining); ordered.extend(remaining);
} }
ordered ordered
} else { } else {
docs_query docs_query
.order(documents::uploaded_at.desc()) .order(documents::created_at.desc())
.load(&mut conn)? .load(&mut conn)?
}; };
@@ -627,7 +627,7 @@ pub async fn check_document(
filename: Some(document.filename.clone()), filename: Some(document.filename.clone()),
version_id: Some(version.id), version_id: Some(version.id),
version_number: Some(version.version_number), version_number: Some(version.version_number),
uploaded_at: Some(to_iso(document.uploaded_at)), created_at: Some(to_iso(document.created_at)),
})) }))
} else { } else {
Ok(Json(DocumentCheckResponse { Ok(Json(DocumentCheckResponse {
@@ -637,7 +637,7 @@ pub async fn check_document(
filename: None, filename: None,
version_id: None, version_id: None,
version_number: None, version_number: None,
uploaded_at: None, created_at: None,
})) }))
} }
} }
@@ -2190,7 +2190,7 @@ pub(crate) fn to_document_response(
original_name: doc.original_name, original_name: doc.original_name,
content_type: doc.content_type, content_type: doc.content_type,
folder_id: doc.folder_id, folder_id: doc.folder_id,
uploaded_at: to_iso(doc.uploaded_at), created_at: to_iso(doc.created_at),
updated_at: to_iso(doc.updated_at), updated_at: to_iso(doc.updated_at),
deleted_at: doc.deleted_at.map(to_iso), deleted_at: doc.deleted_at.map(to_iso),
issued_at: doc.issued_at.map(to_iso), issued_at: doc.issued_at.map(to_iso),
+1 -1
View File
@@ -308,7 +308,7 @@ pub async fn list_folder_contents(
let docs_query = documents::table let docs_query = documents::table
.filter(documents::deleted_at.is_null()) .filter(documents::deleted_at.is_null())
.filter(documents::tenant_id.eq(tenant_id)) .filter(documents::tenant_id.eq(tenant_id))
.order(documents::uploaded_at.desc()); .order(documents::created_at.desc());
let docs: Vec<Document> = if let Some(current_folder) = folder_id { let docs: Vec<Document> = if let Some(current_folder) = folder_id {
docs_query docs_query
+44 -11
View File
@@ -2,7 +2,7 @@ use axum::http::HeaderValue;
use axum::{ use axum::{
extract::DefaultBodyLimit, extract::DefaultBodyLimit,
middleware, middleware,
response::Json, response::{Html, Json},
routing::{delete, get, patch, post}, routing::{delete, get, patch, post},
Router, Router,
}; };
@@ -164,24 +164,26 @@ pub fn create_router(state: AppState) -> Router<()> {
.nest("/api/assets", assets_routes) .nest("/api/assets", assets_routes)
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state)); .layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
let openapi_arc = Arc::new(ApiDoc::openapi()); let upload_limit = state.config.upload_body_limit_bytes;
let docs_route = Router::new().route(
let openapi_spec = Arc::new(ApiDoc::openapi());
let docs_router = Router::new()
.route(
"/api/docs",
get(move || async { Html(render_swagger_ui("/api/docs/openapi.json")) }),
)
.route(
"/api/docs/openapi.json", "/api/docs/openapi.json",
get({ get({
let spec = openapi_arc.clone(); let spec = openapi_spec.clone();
move || { move || async move { Json((*spec).clone()) }
let spec = spec.clone();
async move { Json((*spec).clone()) }
}
}), }),
); );
let upload_limit = state.config.upload_body_limit_bytes;
Router::new() Router::new()
.merge(download_routes) .merge(download_routes)
.merge(protected_routes) .merge(protected_routes)
.merge(docs_route) .merge(docs_router)
.nest("/api/auth", auth_routes) .nest("/api/auth", auth_routes)
.route("/api/health", get(health::health_check)) .route("/api/health", get(health::health_check))
.with_state(state) .with_state(state)
@@ -196,3 +198,34 @@ pub fn create_router(state: AppState) -> Router<()> {
.on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)), .on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)),
) )
} }
fn render_swagger_ui(spec_url: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Papercrate API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
html {{ box-sizing: border-box; font-family: sans-serif; }}
*, *:before, *:after {{ box-sizing: inherit; }}
body {{ margin: 0; background: #fafafa; }}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.addEventListener('load', () => {{
window.ui = SwaggerUIBundle({{
url: '{spec_url}',
dom_id: '#swagger-ui',
deepLinking: true,
}});
}});
</script>
</body>
</html>"#
)
}
+1 -1
View File
@@ -273,7 +273,7 @@ fn fetch_folder_contents(
}; };
let documents: Vec<Document> = docs_query let documents: Vec<Document> = docs_query
.order(documents_dsl::uploaded_at.desc()) .order(documents_dsl::created_at.desc())
.load(&mut conn)?; .load(&mut conn)?;
let version_ids: Vec<Uuid> = documents.iter().map(|doc| doc.current_version_id).collect(); let version_ids: Vec<Uuid> = documents.iter().map(|doc| doc.current_version_id).collect();
+1 -1
View File
@@ -88,7 +88,7 @@ diesel::table! {
#[max_length = 100] #[max_length = 100]
content_type -> Nullable<Varchar>, content_type -> Nullable<Varchar>,
folder_id -> Nullable<Uuid>, folder_id -> Nullable<Uuid>,
uploaded_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
deleted_at -> Nullable<Timestamptz>, deleted_at -> Nullable<Timestamptz>,
metadata -> Jsonb, metadata -> Jsonb,
+2 -1
View File
@@ -37,7 +37,8 @@ use tower::util::ServiceExt;
use uuid::Uuid; use uuid::Uuid;
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
const RESET_SCHEMA_SQL: &str = include_str!("../../migrations/202510300000_initial_schema/down.sql"); const RESET_SCHEMA_SQL: &str =
include_str!("../../migrations/202510300000_initial_schema/down.sql");
static DB_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(())); static DB_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
+1 -1
View File
@@ -22,7 +22,7 @@ fields:
row; updated whenever a new version is promoted. row; updated whenever a new version is promoted.
- `deleted_at (timestamptz, nullable)` Soft-delete marker; non-NULL rows are - `deleted_at (timestamptz, nullable)` Soft-delete marker; non-NULL rows are
treated as living in the trash. treated as living in the trash.
- `uploaded_at / updated_at (timestamptz)` Audit stamps; `updated_at` reflects - `created_at / updated_at (timestamptz)` Audit stamps; `updated_at` reflects
metadata or version changes. metadata or version changes.
Other indexes enforce per-tenant uniqueness for `(folder, filename)` and support Other indexes enforce per-tenant uniqueness for `(folder, filename)` and support
+4 -8
View File
@@ -58,9 +58,8 @@ export const describeDocumentSummary = (document, options = {}) => {
originalName: '', originalName: '',
mimeTypeLabel: '—', mimeTypeLabel: '—',
sizeLabel: '—', sizeLabel: '—',
uploadedAtLabel: '—',
issuedAtLabel: '—',
createdAtLabel: '—', createdAtLabel: '—',
issuedAtLabel: '—',
updatedAtLabel: '—', updatedAtLabel: '—',
pageCount: null, pageCount: null,
pageCountLabel: '—', pageCountLabel: '—',
@@ -88,9 +87,8 @@ export const describeDocumentSummary = (document, options = {}) => {
const pageCount = coercePageCount(metadata); const pageCount = coercePageCount(metadata);
const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—'; const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—';
const uploadedAtLabel = formatDateTime(document.uploaded_at);
const issuedAtLabel = formatDateTime(document.issued_at);
const createdAtLabel = formatDateTime(document.created_at); const createdAtLabel = formatDateTime(document.created_at);
const issuedAtLabel = formatDateTime(document.issued_at);
const updatedAtLabel = formatDateTime(document.updated_at); const updatedAtLabel = formatDateTime(document.updated_at);
const folderLabel = document.folder_path || document.folder_name || null; const folderLabel = document.folder_path || document.folder_name || null;
@@ -107,12 +105,11 @@ export const describeDocumentSummary = (document, options = {}) => {
: '—'; : '—';
const summaryRows = [ const summaryRows = [
{ key: 'uploaded', label: 'Uploaded', value: uploadedAtLabel }, { key: 'created', label: 'Created', value: createdAtLabel },
{ key: 'size', label: 'Size', value: sizeLabel }, { key: 'size', label: 'Size', value: sizeLabel },
{ key: 'type', label: 'Type', value: mimeTypeLabel }, { key: 'type', label: 'Type', value: mimeTypeLabel },
{ key: 'issued', label: 'Issued', value: issuedAtLabel }, { key: 'issued', label: 'Issued', value: issuedAtLabel },
{ key: 'pages', label: 'Pages', value: pageCountLabel }, { key: 'pages', label: 'Pages', value: pageCountLabel },
{ key: 'created', label: 'Created', value: createdAtLabel },
{ key: 'updated', label: 'Updated', value: updatedAtLabel }, { key: 'updated', label: 'Updated', value: updatedAtLabel },
{ key: 'folder', label: 'Folder', value: folderLabel || '—' }, { key: 'folder', label: 'Folder', value: folderLabel || '—' },
{ key: 'tags', label: 'Tags', value: tagsSummary }, { key: 'tags', label: 'Tags', value: tagsSummary },
@@ -124,9 +121,8 @@ export const describeDocumentSummary = (document, options = {}) => {
originalName, originalName,
mimeTypeLabel, mimeTypeLabel,
sizeLabel, sizeLabel,
uploadedAtLabel,
issuedAtLabel,
createdAtLabel, createdAtLabel,
issuedAtLabel,
updatedAtLabel, updatedAtLabel,
pageCount, pageCount,
pageCountLabel, pageCountLabel,
+1 -1
View File
@@ -330,7 +330,7 @@ const isAssetEquivalent = (lhs, rhs) => {
&& lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height && lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height
&& lhs.mime_type === rhs.mime_type && lhs.mime_type === rhs.mime_type
&& lhs.asset_type === rhs.asset_type && lhs.asset_type === rhs.asset_type
&& lhs.created_at === rhs.created_at && lhs.updated_at === rhs.updated_at
&& lhsCardinality === rhsCardinality && lhsCardinality === rhsCardinality
&& objectsComparable && objectsComparable
); );
+87 -32
View File
@@ -13,21 +13,52 @@ const PreviewWorkspace = ({
const title = document.title; const title = document.title;
const summary = describeDocumentSummary(document); const summary = describeDocumentSummary(document);
const baseSummaryRows = summary.summaryRows.filter((row) => { const correspondents = Array.isArray(document.correspondents)
if (row.key === 'pages') { ? document.correspondents.map((entry) => entry?.name).filter(Boolean).join(', ')
return Number.isFinite(summary.pageCount); : '';
const tags = Array.isArray(document.tags)
? document.tags.map((tag) => tag?.label).filter(Boolean).join(', ')
: '';
const formatDateTime = (value) => {
if (!value) {
return '—';
} }
if (row.key === 'folder') { const date = new Date(value);
return Boolean(summary.folderLabel); return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
} };
return true;
}); const detailItems = [
const summaryRows = [ { label: 'Title', value: summary.title || '—' },
...baseSummaryRows, { label: 'Archive Reference', value: document.archive_serial || '—' },
{ label: 'Issued On', value: formatDateTime(document.issued_at) },
{ label: 'Correspondent', value: correspondents || '—' },
{ label: 'Document Type', value: document.document_type || '—' },
{ {
key: 'original-name', label: 'Filename',
label: 'Original filename', value: document.archive_path || document.filename || '—',
value: document.original_name || '—', },
{ label: 'Tags', value: tags || '—' },
];
const metadataItems = [
{ label: 'Modified At', value: formatDateTime(document.updated_at) },
{ label: 'Created At', value: formatDateTime(document.created_at) },
{
label: 'Media Filename',
value: document.current_version?.filename || document.archive_path || '—',
},
{
label: 'SHA-256 Checksum',
value: document.current_version?.checksum || '—',
},
{
label: 'Original File Size',
value: summary.sizeLabel,
},
{
label: 'Original MIME Type',
value: document.content_type || '—',
}, },
]; ];
const metadata = const metadata =
@@ -35,30 +66,54 @@ const PreviewWorkspace = ({
return ( return (
<section className="preview-workspace"> <section className="preview-workspace">
<aside className="preview-workspace__sidebar"> <div className="preview-workspace__details">
<div className="preview-workspace__info"> <section className="preview-section">
{summaryRows.length ? ( <h3 className="preview-section__title">Details</h3>
<dl className="preview-workspace__summary"> <dl className="preview-section__list">
{summaryRows.map(({ key, label, value }) => { {detailItems.map(({ label, value }) => (
const displayValue = <div className="preview-section__item" key={label}>
value === null || value === undefined || value === '' ? '—' : value;
return (
<div className="preview-workspace__summary-row" key={key}>
<dt>{label}</dt> <dt>{label}</dt>
<dd>{displayValue}</dd> <dd>{value || '—'}</dd>
</div> </div>
); ))}
})}
</dl> </dl>
) : null}
</div>
{metadata ? (
<section className="preview-workspace__metadata">
<h4>Metadata payload</h4>
<pre>{JSON.stringify(metadata, null, 2)}</pre>
</section> </section>
<section className="preview-section">
<h3 className="preview-section__title">Content</h3>
<p className="preview-section__placeholder">
Full OCR text will appear here in a future update. Use the toolbar button to open the OCR view for now.
</p>
</section>
<section className="preview-section">
<h3 className="preview-section__title">Metadata</h3>
<dl className="preview-section__list">
{metadataItems.map(({ label, value }) => (
<div className="preview-section__item" key={label}>
<dt>{label}</dt>
<dd>{value || '—'}</dd>
</div>
))}
</dl>
{metadata ? (
<details className="preview-section__payload">
<summary>Show metadata payload</summary>
<pre>{JSON.stringify(metadata, null, 2)}</pre>
</details>
) : null} ) : null}
</aside> </section>
<section className="preview-section">
<h3 className="preview-section__title">Notes</h3>
<p className="preview-section__placeholder">Custom notes will be editable here once the feature lands.</p>
</section>
<section className="preview-section">
<h3 className="preview-section__title">History</h3>
<p className="preview-section__placeholder">Change history will be displayed here in an upcoming release.</p>
</section>
<section className="preview-section">
<h3 className="preview-section__title">Permissions</h3>
<p className="preview-section__placeholder">Access control management is planned and will surface here.</p>
</section>
</div>
<div className="preview-workspace__viewer"> <div className="preview-workspace__viewer">
{!previewEntry?.url ? ( {!previewEntry?.url ? (
<div className="preview-workspace__message">Loading preview</div> <div className="preview-workspace__message">Loading preview</div>
+52 -67
View File
@@ -665,37 +665,13 @@ button.danger:hover:not([disabled]) {
.preview-workspace { .preview-workspace {
flex: 1; flex: 1;
display: flex; display: grid;
grid-template-columns: minmax(0, 30em) minmax(0, 1fr);
gap: 1.5rem; gap: 1.5rem;
min-height: 0; min-height: 0;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
} }
.preview-workspace__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1.5rem 0;
}
.preview-workspace__meta {
display: flex;
align-items: center;
gap: 0.75rem;
}
.preview-workspace__meta h2 {
margin: 0;
font-size: 1.1rem;
}
.preview-workspace__meta .meta {
display: block;
font-size: 0.8rem;
color: var(--muted);
}
.document-drag-preview { .document-drag-preview {
position: fixed; position: fixed;
pointer-events: none; pointer-events: none;
@@ -794,75 +770,84 @@ button.danger:hover:not([disabled]) {
pointer-events: none; pointer-events: none;
} }
.preview-workspace__actions { .preview-workspace__details {
display: flex;
align-items: center;
gap: 0.5rem;
}
.preview-workspace__sidebar {
flex: 0 0 20em;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1.5rem;
max-width: 100%; overflow-y: auto;
overflow: auto; min-height: 0;
} }
.preview-workspace__info { .preview-section {
background: var(--surface-subtle); background: var(--surface-subtle);
padding: 1rem;
box-shadow: inset 0 0 0 1px var(--outline-subtle); box-shadow: inset 0 0 0 1px var(--outline-subtle);
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
} }
.preview-workspace__summary { .preview-section__title {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.preview-section__list {
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
display: flex; display: grid;
flex-direction: column; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.5rem; gap: 0.75rem 1.25rem;
} }
.preview-workspace__summary-row { .preview-section__item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
} }
.preview-workspace__summary-row dt { .preview-section__item dt {
font-size: 0.75rem; font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted); color: var(--muted);
} }
.preview-workspace__summary-row dd { .preview-section__item dd {
margin: 0; margin: 0;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
}
.preview-workspace__metadata {
background: var(--surface-subtle);
padding: 1rem;
box-shadow: inset 0 0 0 1px var(--outline-subtle);
font-size: 0.85rem;
overflow: auto;
max-height: 40vh;
}
.preview-workspace__metadata h4 {
margin: 0 0 0.5rem;
font-size: 0.9rem;
}
.preview-workspace__metadata pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
.preview-section__placeholder {
margin: 0;
font-size: 0.9rem;
color: var(--muted);
}
.preview-section__payload {
font-size: 0.85rem;
}
.preview-section__payload summary {
cursor: pointer;
font-weight: 500;
color: var(--accent-strong, var(--accent));
}
.preview-section__payload pre {
margin: 0.75rem 0 0;
padding: 0.75rem;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--outline-subtle);
border-radius: 6px;
max-height: 280px;
overflow: auto;
font-size: 0.8rem;
}
.preview-workspace__viewer { .preview-workspace__viewer {
flex: 1; flex: 1;
min-width: 0; min-width: 0;