@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.
Files changed (36) hide show
  1. package/app/Actions/Blog/BlogDestroyAction.ts +25 -0
  2. package/app/Actions/Blog/BlogIndexAction.ts +18 -0
  3. package/app/Actions/Blog/BlogShowAction.ts +25 -0
  4. package/app/Actions/Blog/BlogStoreAction.ts +21 -0
  5. package/app/Actions/Blog/BlogUpdateAction.ts +23 -0
  6. package/app/Actions/Blog/payload.ts +56 -0
  7. package/app/Actions/Cms/PostStoreAction.ts +10 -5
  8. package/app/Actions/Dashboard/Content/AuthorDestroyAction.ts +38 -0
  9. package/app/Actions/Dashboard/Content/AuthorIndexAction.ts +53 -39
  10. package/app/Actions/Dashboard/Content/AuthorStoreAction.ts +61 -0
  11. package/app/Actions/Dashboard/Content/CategoryDestroyAction.ts +30 -0
  12. package/app/Actions/Dashboard/Content/CategoryIndexAction.ts +40 -17
  13. package/app/Actions/Dashboard/Content/CategoryStoreAction.ts +58 -0
  14. package/app/Actions/Dashboard/Content/CommentDestroyAction.ts +30 -0
  15. package/app/Actions/Dashboard/Content/CommentIndexAction.ts +46 -31
  16. package/app/Actions/Dashboard/Content/CommentUpdateAction.ts +48 -0
  17. package/app/Actions/Dashboard/Content/PageDestroyAction.ts +25 -0
  18. package/app/Actions/Dashboard/Content/PageIndexAction.ts +43 -15
  19. package/app/Actions/Dashboard/Content/PageStoreAction.ts +49 -0
  20. package/app/Actions/Dashboard/Content/PostDestroyAction.ts +37 -0
  21. package/app/Actions/Dashboard/Content/PostIndexAction.ts +64 -83
  22. package/app/Actions/Dashboard/Content/PostStoreAction.ts +50 -0
  23. package/app/Actions/Dashboard/Content/PostUpdateAction.ts +54 -0
  24. package/app/Actions/Dashboard/Content/TagDestroyAction.ts +30 -0
  25. package/app/Actions/Dashboard/Content/TagIndexAction.ts +39 -13
  26. package/app/Actions/Dashboard/Content/TagStoreAction.ts +60 -0
  27. package/app/Actions/Dashboard/Content/comment-input.ts +35 -0
  28. package/app/Actions/Dashboard/Content/content-input.ts +65 -0
  29. package/app/Actions/Dashboard/Content/post-input.ts +54 -0
  30. package/app/Models/Content/Post.ts +4 -1
  31. package/package.json +1 -1
  32. package/resources/components/Dashboard/FileViewer/MediaCard.stx +119 -0
  33. package/resources/components/Dashboard/FileViewer/MediaInfoPanel.stx +143 -0
  34. package/resources/components/Dashboard/SidebarModern.stx +1 -0
  35. package/resources/functions/dashboard/files-tree.ts +162 -0
  36. package/resources/functions/dashboard/sidebar.ts +1 -0
@@ -1,23 +1,49 @@
1
1
  import { Action } from '@stacksjs/actions'
2
- import { Tag } from '@stacksjs/orm'
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 data for the dashboard.',
26
+ description: 'Returns CMS tags for the dashboard.',
7
27
  method: 'GET',
28
+ apiResponse: true,
8
29
  async handle() {
9
- try {
10
- const allTags = await Tag.all()
30
+ const rows = await db
31
+ .selectFrom('tags')
32
+ .selectAll()
33
+ .orderBy('created_at', 'desc')
34
+ .execute() as unknown as TagRow[]
11
35
 
12
- const tags = allTags.map(t => ({
13
- name: String(t.get('name') || ''),
14
- count: Number(t.get('count') || t.get('post_count') || 0),
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
- return { tags }
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: true,
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.88",
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' },