@stacksjs/defaults 0.70.88 → 0.70.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/Actions/Blog/BlogDestroyAction.ts +25 -0
- package/app/Actions/Blog/BlogIndexAction.ts +18 -0
- package/app/Actions/Blog/BlogShowAction.ts +25 -0
- package/app/Actions/Blog/BlogStoreAction.ts +21 -0
- package/app/Actions/Blog/BlogUpdateAction.ts +23 -0
- package/app/Actions/Blog/payload.ts +56 -0
- package/app/Actions/Cms/PostStoreAction.ts +10 -5
- package/app/Actions/Dashboard/Content/AuthorDestroyAction.ts +38 -0
- package/app/Actions/Dashboard/Content/AuthorIndexAction.ts +53 -39
- package/app/Actions/Dashboard/Content/AuthorStoreAction.ts +61 -0
- package/app/Actions/Dashboard/Content/CategoryDestroyAction.ts +30 -0
- package/app/Actions/Dashboard/Content/CategoryIndexAction.ts +40 -17
- package/app/Actions/Dashboard/Content/CategoryStoreAction.ts +58 -0
- package/app/Actions/Dashboard/Content/CommentDestroyAction.ts +30 -0
- package/app/Actions/Dashboard/Content/CommentIndexAction.ts +46 -31
- package/app/Actions/Dashboard/Content/CommentUpdateAction.ts +48 -0
- package/app/Actions/Dashboard/Content/PageDestroyAction.ts +25 -0
- package/app/Actions/Dashboard/Content/PageIndexAction.ts +43 -15
- package/app/Actions/Dashboard/Content/PageStoreAction.ts +49 -0
- package/app/Actions/Dashboard/Content/PostDestroyAction.ts +37 -0
- package/app/Actions/Dashboard/Content/PostIndexAction.ts +64 -83
- package/app/Actions/Dashboard/Content/PostStoreAction.ts +50 -0
- package/app/Actions/Dashboard/Content/PostUpdateAction.ts +54 -0
- package/app/Actions/Dashboard/Content/TagDestroyAction.ts +30 -0
- package/app/Actions/Dashboard/Content/TagIndexAction.ts +39 -13
- package/app/Actions/Dashboard/Content/TagStoreAction.ts +60 -0
- package/app/Actions/Dashboard/Content/comment-input.ts +35 -0
- package/app/Actions/Dashboard/Content/content-input.ts +65 -0
- package/app/Actions/Dashboard/Content/post-input.ts +54 -0
- package/app/Models/Content/Post.ts +4 -1
- package/package.json +1 -1
- package/resources/components/Dashboard/FileViewer/MediaCard.stx +119 -0
- package/resources/components/Dashboard/FileViewer/MediaInfoPanel.stx +143 -0
- package/resources/components/Dashboard/SidebarModern.stx +1 -0
- package/resources/functions/dashboard/files-tree.ts +162 -0
- package/resources/functions/dashboard/sidebar.ts +1 -0
|
@@ -1,23 +1,49 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
|
-
import {
|
|
2
|
+
import { db } from '@stacksjs/database'
|
|
3
3
|
|
|
4
|
+
interface TagRow {
|
|
5
|
+
id: number
|
|
6
|
+
name: string | null
|
|
7
|
+
slug: string | null
|
|
8
|
+
description: string | null
|
|
9
|
+
post_count: number | null
|
|
10
|
+
color: string | null
|
|
11
|
+
created_at: string | null
|
|
12
|
+
updated_at: string | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `GET /api/dashboard/tags` — backs `views/dashboard/content/tags/index.stx`.
|
|
17
|
+
*
|
|
18
|
+
* Reads the `tags` table via `db`. The previous `Tag.all()` call threw on every
|
|
19
|
+
* request (the ORM model exposes no query methods) and the catch turned that
|
|
20
|
+
* into an empty list, so a broken read looked like a CMS with no tags. It also
|
|
21
|
+
* returned only `{ name, count }`, while the page renders id, slug, and
|
|
22
|
+
* description.
|
|
23
|
+
*/
|
|
4
24
|
export default new Action({
|
|
5
25
|
name: 'TagIndexAction',
|
|
6
|
-
description: 'Returns tags
|
|
26
|
+
description: 'Returns CMS tags for the dashboard.',
|
|
7
27
|
method: 'GET',
|
|
28
|
+
apiResponse: true,
|
|
8
29
|
async handle() {
|
|
9
|
-
|
|
10
|
-
|
|
30
|
+
const rows = await db
|
|
31
|
+
.selectFrom('tags')
|
|
32
|
+
.selectAll()
|
|
33
|
+
.orderBy('created_at', 'desc')
|
|
34
|
+
.execute() as unknown as TagRow[]
|
|
11
35
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
36
|
+
const tags = rows.map(row => ({
|
|
37
|
+
id: Number(row.id),
|
|
38
|
+
name: String(row.name || ''),
|
|
39
|
+
slug: String(row.slug || ''),
|
|
40
|
+
description: String(row.description || ''),
|
|
41
|
+
color: String(row.color || ''),
|
|
42
|
+
post_count: Number(row.post_count || 0),
|
|
43
|
+
created_at: row.created_at || null,
|
|
44
|
+
updated_at: row.updated_at || null,
|
|
45
|
+
}))
|
|
16
46
|
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
catch {
|
|
20
|
-
return { tags: [] }
|
|
21
|
-
}
|
|
47
|
+
return { tags }
|
|
22
48
|
},
|
|
23
49
|
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { db } from '@stacksjs/database'
|
|
4
|
+
import { response } from '@stacksjs/router'
|
|
5
|
+
import { randomUUIDv7 } from 'bun'
|
|
6
|
+
import { findRow, insertedId, slugify, str, timestamp } from './content-input'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `POST /api/dashboard/tags` — creates a CMS tag from the dashboard.
|
|
10
|
+
*
|
|
11
|
+
* The `Tag` model declares `name` and `slug` unique but the table carries no
|
|
12
|
+
* unique constraint, so the duplicate check has to happen here.
|
|
13
|
+
*/
|
|
14
|
+
export default new Action({
|
|
15
|
+
name: 'TagStoreAction',
|
|
16
|
+
description: 'Creates a CMS tag from the dashboard.',
|
|
17
|
+
method: 'POST',
|
|
18
|
+
async handle(request: RequestInstance) {
|
|
19
|
+
const name = str(request.get('name')).trim()
|
|
20
|
+
const description = str(request.get('description'))
|
|
21
|
+
const slug = slugify(str(request.get('slug')) || name)
|
|
22
|
+
|
|
23
|
+
if (!name)
|
|
24
|
+
return response.json({ message: 'Name is required.' }, 422)
|
|
25
|
+
|
|
26
|
+
if (!slug)
|
|
27
|
+
return response.json({ message: 'Slug could not be derived from the name; enter one.' }, 422)
|
|
28
|
+
|
|
29
|
+
const duplicate = await db
|
|
30
|
+
.selectFrom('tags')
|
|
31
|
+
.select(['id'])
|
|
32
|
+
.where('slug', '=', slug)
|
|
33
|
+
.executeTakeFirst()
|
|
34
|
+
|
|
35
|
+
if (duplicate)
|
|
36
|
+
return response.json({ message: 'A tag with that slug already exists.' }, 422)
|
|
37
|
+
|
|
38
|
+
const now = timestamp()
|
|
39
|
+
|
|
40
|
+
const result = await db
|
|
41
|
+
.insertInto('tags')
|
|
42
|
+
.values({
|
|
43
|
+
uuid: randomUUIDv7(),
|
|
44
|
+
name,
|
|
45
|
+
slug,
|
|
46
|
+
description,
|
|
47
|
+
post_count: 0,
|
|
48
|
+
created_at: now,
|
|
49
|
+
updated_at: now,
|
|
50
|
+
} as any)
|
|
51
|
+
.executeTakeFirst()
|
|
52
|
+
|
|
53
|
+
const id = insertedId(result)
|
|
54
|
+
|
|
55
|
+
if (!id)
|
|
56
|
+
return response.json({ message: 'Could not create tag.' }, 500)
|
|
57
|
+
|
|
58
|
+
return response.json(await findRow('tags', id), 201)
|
|
59
|
+
},
|
|
60
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { str } from './content-input'
|
|
2
|
+
|
|
3
|
+
/** The `comments.status` CHECK constraint, in one place. */
|
|
4
|
+
export const COMMENT_STATUSES = ['pending', 'approved', 'spam', 'trash'] as const
|
|
5
|
+
|
|
6
|
+
export type CommentStatus = typeof COMMENT_STATUSES[number]
|
|
7
|
+
|
|
8
|
+
function isCommentStatus(value: string): value is CommentStatus {
|
|
9
|
+
return (COMMENT_STATUSES as readonly string[]).includes(value)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read-side normalization: fold casing and fall back to 'pending'.
|
|
14
|
+
*
|
|
15
|
+
* The CHECK constraint keeps SQL honest, but rows predating it (and hand-written
|
|
16
|
+
* seeds) have been seen with 'Pending', so the dashboard's status filter and
|
|
17
|
+
* badge styling only ever see one casing.
|
|
18
|
+
*/
|
|
19
|
+
export function normalizeCommentStatus(value: unknown): CommentStatus {
|
|
20
|
+
const status = str(value).toLowerCase()
|
|
21
|
+
|
|
22
|
+
return isCommentStatus(status) ? status : 'pending'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Write-side validation: an unknown status is rejected rather than coerced.
|
|
27
|
+
*
|
|
28
|
+
* A moderation write silently landing as 'pending' would be worse than an
|
|
29
|
+
* error, and the CHECK constraint would reject it anyway — as a 500.
|
|
30
|
+
*/
|
|
31
|
+
export function parseCommentStatus(value: unknown): CommentStatus | undefined {
|
|
32
|
+
const status = str(value).toLowerCase()
|
|
33
|
+
|
|
34
|
+
return isCommentStatus(status) ? status : undefined
|
|
35
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { db } from '@stacksjs/database'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Helpers shared by the `Dashboard/Content` CRUD actions.
|
|
6
|
+
*
|
|
7
|
+
* These actions talk to `db` directly rather than going through the ORM models
|
|
8
|
+
* or `@stacksjs/cms`: the models expose no query methods, and the CMS helpers
|
|
9
|
+
* assume columns and pivot tables this schema does not have. See
|
|
10
|
+
* `PostIndexAction` for the longer version of that story.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export function str(value: unknown): string {
|
|
14
|
+
return value == null ? '' : String(value)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `YYYY-MM-DD HH:MM:SS`, matching the tables' CURRENT_TIMESTAMP default. */
|
|
18
|
+
export function timestamp(): string {
|
|
19
|
+
return new Date().toISOString().slice(0, 19).replace('T', ' ')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The new row's id, across the driver shapes we may get back from an insert. */
|
|
23
|
+
export function insertedId(result: unknown): number {
|
|
24
|
+
const row = result as { lastInsertRowid?: number | bigint, insertId?: number | bigint } | undefined
|
|
25
|
+
|
|
26
|
+
return Number(row?.lastInsertRowid ?? row?.insertId ?? 0)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The `{id}` route param, or 0 when it is not a usable row id. */
|
|
30
|
+
export function rowId(request: RequestInstance): number {
|
|
31
|
+
const id = Number(request.getParam('id'))
|
|
32
|
+
|
|
33
|
+
return Number.isInteger(id) && id > 0 ? id : 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Matches the slug the dashboard dialogs generate client-side. */
|
|
37
|
+
export function slugify(value: string): string {
|
|
38
|
+
return value
|
|
39
|
+
.trim()
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/['"]/g, '')
|
|
42
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
43
|
+
.replace(/^-+|-+$/g, '')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Reads a row back after a write.
|
|
48
|
+
*
|
|
49
|
+
* The SQLite driver in use does not honour `RETURNING` — `.returningAll()`
|
|
50
|
+
* resolves to a `{ changes, lastInsertRowid }` summary rather than the row — so
|
|
51
|
+
* writes re-select instead of trusting the insert/update result.
|
|
52
|
+
*
|
|
53
|
+
* `table` is a literal at every call site; the cast is only here because the
|
|
54
|
+
* query builder's types want a table union rather than a `string`.
|
|
55
|
+
*/
|
|
56
|
+
export async function findRow(table: string, id: number): Promise<unknown> {
|
|
57
|
+
return await (db as any).selectFrom(table).selectAll().where('id', '=', id).executeTakeFirst()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether a row with this id exists — the 404 check every write shares. */
|
|
61
|
+
export async function rowExists(table: string, id: number): Promise<boolean> {
|
|
62
|
+
const row = await (db as any).selectFrom(table).select(['id']).where('id', '=', id).executeTakeFirst()
|
|
63
|
+
|
|
64
|
+
return Boolean(row)
|
|
65
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { db } from '@stacksjs/database'
|
|
3
|
+
import { str } from './content-input'
|
|
4
|
+
|
|
5
|
+
export { insertedId, timestamp } from './content-input'
|
|
6
|
+
|
|
7
|
+
export interface PostPayload {
|
|
8
|
+
title: string
|
|
9
|
+
excerpt: string
|
|
10
|
+
content: string
|
|
11
|
+
poster: string
|
|
12
|
+
status: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The `posts` table has a CHECK constraint on ('published', 'draft', 'archived'),
|
|
17
|
+
* so anything else — including a capitalized 'Draft' — fails the insert. Fold
|
|
18
|
+
* casing and fall back to 'draft' rather than letting a bad value reach SQL.
|
|
19
|
+
*/
|
|
20
|
+
export function normalizeStatus(value: unknown): string {
|
|
21
|
+
const status = str(value).toLowerCase()
|
|
22
|
+
|
|
23
|
+
return status === 'published' || status === 'archived' ? status : 'draft'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A post only carries a `published_at` while it is published.
|
|
28
|
+
*
|
|
29
|
+
* An already-published post keeps its original timestamp so re-saving doesn't
|
|
30
|
+
* silently republish it, and unpublishing clears the date instead of leaving a
|
|
31
|
+
* stale one behind.
|
|
32
|
+
*/
|
|
33
|
+
export function publishedAtFor(status: string, existing: string | null, now: string): string | null {
|
|
34
|
+
if (status !== 'published')
|
|
35
|
+
return null
|
|
36
|
+
|
|
37
|
+
return existing || now
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Reads a post row back after a write — see `findRow` for why writes re-select. */
|
|
41
|
+
export async function findPost(id: number): Promise<unknown> {
|
|
42
|
+
return await db.selectFrom('posts').selectAll().where('id', '=', id).executeTakeFirst()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Maps a dashboard request body onto the writable post columns. */
|
|
46
|
+
export function postPayload(request: RequestInstance): PostPayload {
|
|
47
|
+
return {
|
|
48
|
+
title: str(request.get('title')).trim(),
|
|
49
|
+
excerpt: str(request.get('excerpt')),
|
|
50
|
+
content: str(request.get('content') || request.get('body')),
|
|
51
|
+
poster: str(request.get('poster')),
|
|
52
|
+
status: normalizeStatus(request.get('status')),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -21,7 +21,10 @@ export default defineModel({
|
|
|
21
21
|
// rendered by BunPress; this model backs the CMS dashboard only.
|
|
22
22
|
categorizable: true,
|
|
23
23
|
taggable: true,
|
|
24
|
-
commentables
|
|
24
|
+
// `commentable`, not `commentables`: define-model checks the singular key,
|
|
25
|
+
// so the plural spelling left the trait inert. Now that the commentable
|
|
26
|
+
// trait targets the real `commentables` table, activating it is correct.
|
|
27
|
+
commentable: true,
|
|
25
28
|
useApi: {
|
|
26
29
|
uri: 'posts',
|
|
27
30
|
routes: ['index', 'store', 'show', 'update', 'destroy'],
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.70.
|
|
5
|
+
"version": "0.70.90",
|
|
6
6
|
"description": "Default Stacks scaffold resources (views, layouts, components, preloader) — shipped so apps consuming the framework from node_modules get the same fallback resources a vendored checkout provides. Source of truth: storage/framework/defaults.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"license": "MIT",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
MediaCard — Meema-style visual file preview
|
|
3
|
+
|
|
4
|
+
Ported from meemalabs/file-manager (MediaCard/{Image,Video,Audio,Document}.vue)
|
|
5
|
+
to stx + Crosswind. Presentational: it renders the preview + label for one
|
|
6
|
+
file item and derives the media "kind" from `mime_type` (preferred) or the
|
|
7
|
+
file extension in `type`. Real thumbnails render when the item carries a
|
|
8
|
+
`thumbnail`/`url`; otherwise a kind-appropriate placeholder is shown, so it
|
|
9
|
+
works with both live storage listings and demo data.
|
|
10
|
+
|
|
11
|
+
The parent supplies the card frame (border, rounded, hover, click, overflow)
|
|
12
|
+
and any hover action buttons; this component only fills the interior.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
<MediaCard :item="item" />
|
|
16
|
+
-->
|
|
17
|
+
|
|
18
|
+
<script>
|
|
19
|
+
interface FileItem {
|
|
20
|
+
id: number
|
|
21
|
+
name: string
|
|
22
|
+
type: string
|
|
23
|
+
size?: number
|
|
24
|
+
mime_type?: string
|
|
25
|
+
url?: string
|
|
26
|
+
thumbnail?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Props {
|
|
30
|
+
item: FileItem
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { item } = defineProps<Props>()
|
|
34
|
+
|
|
35
|
+
const IMAGE_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'bmp', 'heic']
|
|
36
|
+
const VIDEO_EXT = ['mp4', 'mov', 'webm', 'mkv', 'avi', 'm4v']
|
|
37
|
+
const AUDIO_EXT = ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac']
|
|
38
|
+
const ARCHIVE_EXT = ['zip', 'rar', 'gz', 'tar', '7z']
|
|
39
|
+
const DOC_EXT = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv', 'key', 'pages']
|
|
40
|
+
|
|
41
|
+
function kindOf(it: FileItem): string {
|
|
42
|
+
if (it.type === 'folder')
|
|
43
|
+
return 'folder'
|
|
44
|
+
const mime = (it.mime_type || '').toLowerCase()
|
|
45
|
+
if (mime.startsWith('image/'))
|
|
46
|
+
return 'image'
|
|
47
|
+
if (mime.startsWith('video/'))
|
|
48
|
+
return 'video'
|
|
49
|
+
if (mime.startsWith('audio/'))
|
|
50
|
+
return 'audio'
|
|
51
|
+
const ext = (it.type || '').toLowerCase()
|
|
52
|
+
if (IMAGE_EXT.includes(ext))
|
|
53
|
+
return 'image'
|
|
54
|
+
if (VIDEO_EXT.includes(ext))
|
|
55
|
+
return 'video'
|
|
56
|
+
if (AUDIO_EXT.includes(ext))
|
|
57
|
+
return 'audio'
|
|
58
|
+
if (ARCHIVE_EXT.includes(ext))
|
|
59
|
+
return 'archive'
|
|
60
|
+
if (DOC_EXT.includes(ext))
|
|
61
|
+
return 'document'
|
|
62
|
+
return 'other'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const kind = kindOf(item)
|
|
66
|
+
const ext = (item.type || '').toUpperCase()
|
|
67
|
+
const previewUrl = item.thumbnail || item.url || ''
|
|
68
|
+
const hasImage = kind === 'image' && !!previewUrl
|
|
69
|
+
|
|
70
|
+
// A soft, kind-specific backdrop for the preview area (Meema uses colored
|
|
71
|
+
// treatments per media type). Assembled as full class strings because stx
|
|
72
|
+
// binds dynamic classes via :class, not via {{ }} inside class attributes.
|
|
73
|
+
const backdropByKind: Record<string, string> = {
|
|
74
|
+
image: 'bg-gradient-to-br from-sky-50 to-indigo-100 dark:from-neutral-800 dark:to-neutral-900',
|
|
75
|
+
video: 'bg-gradient-to-br from-neutral-800 to-neutral-950',
|
|
76
|
+
audio: 'bg-gradient-to-br from-fuchsia-50 to-purple-100 dark:from-neutral-800 dark:to-neutral-900',
|
|
77
|
+
document: 'bg-gradient-to-br from-stone-50 to-stone-200 dark:from-neutral-800 dark:to-neutral-900',
|
|
78
|
+
archive: 'bg-gradient-to-br from-amber-50 to-amber-100 dark:from-neutral-800 dark:to-neutral-900',
|
|
79
|
+
folder: 'bg-gradient-to-br from-indigo-50 to-indigo-100 dark:from-neutral-800 dark:to-neutral-900',
|
|
80
|
+
other: 'bg-gradient-to-br from-stone-50 to-stone-200 dark:from-neutral-800 dark:to-neutral-900',
|
|
81
|
+
}
|
|
82
|
+
const glyphByKind: Record<string, string> = {
|
|
83
|
+
image: 'i-hugeicons-image-01 text-indigo-400/80 dark:text-indigo-300/70',
|
|
84
|
+
audio: 'i-hugeicons-music-note-01 text-purple-400/80 dark:text-purple-300/70',
|
|
85
|
+
document: 'i-hugeicons-file-01 text-stone-400 dark:text-neutral-400',
|
|
86
|
+
archive: 'i-hugeicons-folder-file-storage text-amber-500/80 dark:text-amber-300/70',
|
|
87
|
+
folder: 'i-hugeicons-folder-02 text-indigo-400 dark:text-indigo-300',
|
|
88
|
+
other: 'i-hugeicons-file-02 text-stone-400 dark:text-neutral-400',
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const previewClass = `relative flex items-center justify-center h-32 w-full overflow-hidden ${backdropByKind[kind] || backdropByKind.other}`
|
|
92
|
+
const glyphClass = `h-10 w-10 ${glyphByKind[kind] || glyphByKind.other}`
|
|
93
|
+
const badgeClass = kind === 'video'
|
|
94
|
+
? 'bg-black/55 text-white'
|
|
95
|
+
: 'bg-white/85 text-stone-600 dark:bg-black/40 dark:text-neutral-200'
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
<div class="flex flex-col">
|
|
99
|
+
<div :class="previewClass">
|
|
100
|
+
@if (hasImage)
|
|
101
|
+
<img src="{{ previewUrl }}" alt="{{ item.name }}" loading="lazy" class="object-cover h-full w-full" />
|
|
102
|
+
@elseif (kind === 'video')
|
|
103
|
+
<div class="flex items-center justify-center h-12 w-12 bg-white/90 rounded-full shadow-md">
|
|
104
|
+
<div class="ml-0.5 h-5 w-5 text-neutral-900 i-hugeicons-play"></div>
|
|
105
|
+
</div>
|
|
106
|
+
@else
|
|
107
|
+
<div :class="glyphClass"></div>
|
|
108
|
+
@endif
|
|
109
|
+
|
|
110
|
+
@if (item.type !== 'folder' && ext)
|
|
111
|
+
<span class="{{ badgeClass }} absolute left-2 top-2 px-1.5 py-0.5 font-semibold text-[10px] tracking-wide rounded">{{ ext }}</span>
|
|
112
|
+
@endif
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div class="px-3 py-2.5">
|
|
116
|
+
<p class="font-medium text-[13px] text-stone-800 truncate dark:text-white" title="{{ item.name }}">{{ item.name }}</p>
|
|
117
|
+
<p class="mt-0.5 capitalize text-[11px] text-stone-400 dark:text-neutral-500">{{ item.type === 'folder' ? 'Folder' : kind }}</p>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
MediaInfoPanel — file preview + metadata
|
|
3
|
+
|
|
4
|
+
Ported from meemalabs/file-manager (MediaInformation/{FilePreviewSection,
|
|
5
|
+
MetaDataSection,SharingSection}.vue) to stx + Crosswind. Presentational: it
|
|
6
|
+
renders a large preview and a metadata list for one selected file. The parent
|
|
7
|
+
owns the slide-over shell (visibility, positioning, close button) and passes
|
|
8
|
+
the current item in.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
<MediaInfoPanel :item="selectedItem()" />
|
|
12
|
+
-->
|
|
13
|
+
|
|
14
|
+
<script>
|
|
15
|
+
interface FileItem {
|
|
16
|
+
id: number
|
|
17
|
+
name: string
|
|
18
|
+
type: string
|
|
19
|
+
size?: number
|
|
20
|
+
path?: string
|
|
21
|
+
mime_type?: string
|
|
22
|
+
url?: string
|
|
23
|
+
thumbnail?: string
|
|
24
|
+
lastModified?: string
|
|
25
|
+
starred?: boolean
|
|
26
|
+
shared?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Props {
|
|
30
|
+
item: FileItem
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { item } = defineProps<Props>()
|
|
34
|
+
|
|
35
|
+
const IMAGE_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'bmp', 'heic']
|
|
36
|
+
const VIDEO_EXT = ['mp4', 'mov', 'webm', 'mkv', 'avi', 'm4v']
|
|
37
|
+
const AUDIO_EXT = ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac']
|
|
38
|
+
|
|
39
|
+
function kindOf(it: FileItem): string {
|
|
40
|
+
if (it.type === 'folder')
|
|
41
|
+
return 'folder'
|
|
42
|
+
const mime = (it.mime_type || '').toLowerCase()
|
|
43
|
+
if (mime.startsWith('image/'))
|
|
44
|
+
return 'image'
|
|
45
|
+
if (mime.startsWith('video/'))
|
|
46
|
+
return 'video'
|
|
47
|
+
if (mime.startsWith('audio/'))
|
|
48
|
+
return 'audio'
|
|
49
|
+
const ext = (it.type || '').toLowerCase()
|
|
50
|
+
if (IMAGE_EXT.includes(ext))
|
|
51
|
+
return 'image'
|
|
52
|
+
if (VIDEO_EXT.includes(ext))
|
|
53
|
+
return 'video'
|
|
54
|
+
if (AUDIO_EXT.includes(ext))
|
|
55
|
+
return 'audio'
|
|
56
|
+
return 'document'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const kind = kindOf(item)
|
|
60
|
+
const previewUrl = item.thumbnail || item.url || ''
|
|
61
|
+
const hasImage = kind === 'image' && !!previewUrl
|
|
62
|
+
|
|
63
|
+
const glyphByKind: Record<string, string> = {
|
|
64
|
+
image: 'i-hugeicons-image-01 text-indigo-300',
|
|
65
|
+
video: 'i-hugeicons-video-01 text-neutral-300',
|
|
66
|
+
audio: 'i-hugeicons-music-note-01 text-purple-300',
|
|
67
|
+
folder: 'i-hugeicons-folder-02 text-indigo-300',
|
|
68
|
+
document: 'i-hugeicons-file-01 text-stone-300',
|
|
69
|
+
}
|
|
70
|
+
const glyphClass = `h-14 w-14 ${glyphByKind[kind] || glyphByKind.document}`
|
|
71
|
+
const previewBg = kind === 'video'
|
|
72
|
+
? 'bg-gradient-to-br from-neutral-800 to-neutral-950'
|
|
73
|
+
: 'bg-gradient-to-br from-stone-100 to-stone-200 dark:from-neutral-800 dark:to-neutral-900'
|
|
74
|
+
|
|
75
|
+
function formatBytes(bytes: number): string {
|
|
76
|
+
if (!bytes)
|
|
77
|
+
return '0 Bytes'
|
|
78
|
+
const k = 1024
|
|
79
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
|
80
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
81
|
+
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function formatWhen(value?: string): string {
|
|
85
|
+
if (!value)
|
|
86
|
+
return '-'
|
|
87
|
+
const d = new Date(value)
|
|
88
|
+
return Number.isNaN(d.getTime())
|
|
89
|
+
? '-'
|
|
90
|
+
: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const sizeLabel = item.type === 'folder' ? '-' : formatBytes(item.size ?? 0)
|
|
94
|
+
const modifiedLabel = formatWhen(item.lastModified)
|
|
95
|
+
const locationLabel = item.path || '/'
|
|
96
|
+
const kindLabel = item.type === 'folder' ? 'Folder' : `${kind}${item.type ? ` (.${item.type})` : ''}`
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<div class="flex flex-col">
|
|
100
|
+
<div :class="previewBg" class="flex overflow-hidden items-center justify-center h-48 rounded-xl">
|
|
101
|
+
@if (hasImage)
|
|
102
|
+
<img src="{{ previewUrl }}" alt="{{ item.name }}" class="object-contain h-full w-full" />
|
|
103
|
+
@elseif (kind === 'video')
|
|
104
|
+
<div class="flex items-center justify-center h-14 w-14 bg-white/90 rounded-full shadow-md">
|
|
105
|
+
<div class="ml-0.5 h-6 w-6 text-neutral-900 i-hugeicons-play"></div>
|
|
106
|
+
</div>
|
|
107
|
+
@else
|
|
108
|
+
<div :class="glyphClass"></div>
|
|
109
|
+
@endif
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<h4 class="mt-4 break-words font-semibold text-[15px] text-stone-800 dark:text-white">{{ item.name }}</h4>
|
|
113
|
+
|
|
114
|
+
<dl class="mt-4 space-y-3 text-[13px]">
|
|
115
|
+
<div class="flex gap-4 items-start justify-between">
|
|
116
|
+
<dt class="text-stone-400 dark:text-neutral-500">Type</dt>
|
|
117
|
+
<dd class="capitalize font-medium text-right text-stone-700 dark:text-neutral-200">{{ kindLabel }}</dd>
|
|
118
|
+
</div>
|
|
119
|
+
<div class="flex gap-4 items-start justify-between">
|
|
120
|
+
<dt class="text-stone-400 dark:text-neutral-500">Size</dt>
|
|
121
|
+
<dd class="font-medium text-right text-stone-700 dark:text-neutral-200">{{ sizeLabel }}</dd>
|
|
122
|
+
</div>
|
|
123
|
+
<div class="flex gap-4 items-start justify-between">
|
|
124
|
+
<dt class="text-stone-400 dark:text-neutral-500">Location</dt>
|
|
125
|
+
<dd class="break-all font-medium text-right text-stone-700 dark:text-neutral-200">{{ locationLabel }}</dd>
|
|
126
|
+
</div>
|
|
127
|
+
<div class="flex gap-4 items-start justify-between">
|
|
128
|
+
<dt class="text-stone-400 dark:text-neutral-500">Modified</dt>
|
|
129
|
+
<dd class="font-medium text-right text-stone-700 dark:text-neutral-200">{{ modifiedLabel }}</dd>
|
|
130
|
+
</div>
|
|
131
|
+
</dl>
|
|
132
|
+
|
|
133
|
+
<div class="flex gap-2 mt-5">
|
|
134
|
+
<span :show="item.starred" class="inline-flex gap-1 items-center px-2 py-1 font-medium text-[11px] text-yellow-700 dark:text-yellow-300 bg-yellow-100 dark:bg-yellow-500/15 rounded-full">
|
|
135
|
+
<div class="h-3 w-3 i-hugeicons-star"></div>
|
|
136
|
+
Starred
|
|
137
|
+
</span>
|
|
138
|
+
<span :show="item.shared" class="inline-flex gap-1 items-center px-2 py-1 font-medium text-[11px] text-indigo-700 dark:text-indigo-300 bg-indigo-100 dark:bg-indigo-500/15 rounded-full">
|
|
139
|
+
<div class="h-3 w-3 i-hugeicons-share-01"></div>
|
|
140
|
+
Shared
|
|
141
|
+
</span>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
@@ -97,6 +97,7 @@ const sections = [
|
|
|
97
97
|
items: [
|
|
98
98
|
{ id: 'content-dashboard', label: 'Dashboard', icon: 'i-hugeicons-dashboard-speed-01', href: '/content/dashboard' },
|
|
99
99
|
{ id: 'files', label: 'Files', icon: 'i-hugeicons-apple-finder', href: '/content/files' },
|
|
100
|
+
{ id: 'blog', label: 'Blog', icon: 'i-hugeicons-quill-write-02', href: '/content/blog' },
|
|
100
101
|
{ id: 'pages', label: 'Pages', icon: 'i-hugeicons-file-02', href: '/content/pages' },
|
|
101
102
|
{ id: 'posts', label: 'Posts', icon: 'i-hugeicons-message-edit-02', href: '/content/posts' },
|
|
102
103
|
{ id: 'categories', label: 'Categories', icon: 'i-hugeicons-tags', href: '/content/categories' },
|