From 699815c89e80e30dd0e55b3343aba6e83e2e13fa Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Wed, 24 Sep 2025 17:32:16 +0200 Subject: [PATCH] feat: Initialize media feature Co-authored-by: Baptiste Leproux --- src/app/src/App.vue | 19 +- src/app/src/components/CommitPreviewModal.vue | 4 +- .../components/panel/base/PanelBaseBody.vue | 42 ++++ .../panel/base/PanelBaseBodyEditor.vue | 25 ++ .../PanelBaseBodyTree.vue} | 8 +- .../components/panel/base/PanelBaseHeader.vue | 18 +- .../panel/base/PanelBaseSubHeader.vue | 21 ++ .../components/panel/content/PanelContent.vue | 38 ---- .../content/editor/PanelContentEditor.vue | 12 +- .../content/editor/PanelContentEditorCode.vue | 8 +- .../panel/media/PanelMediaEditor.vue | 17 ++ .../components/shared/item/ItemBreadcrumb.vue | 11 +- src/app/src/composables/useContext.ts | 12 +- ...{useDraftFiles.ts => useDraftDocuments.ts} | 73 +++--- src/app/src/composables/useDraftMedias.ts | 215 ++++++++++++++++++ src/app/src/composables/useMediaTree.ts | 93 ++++++++ src/app/src/composables/useStudio.ts | 36 +-- src/app/src/composables/useTree.ts | 28 ++- src/app/src/composables/useUi.ts | 1 - src/app/src/types/context.ts | 5 +- src/app/src/types/database.ts | 8 +- src/app/src/types/draft.ts | 50 ++-- src/app/src/types/index.ts | 11 + src/app/src/types/item.ts | 6 + src/app/src/types/media.ts | 5 + src/app/src/utils/context.ts | 7 +- src/app/src/utils/draft.ts | 16 +- src/app/src/utils/tree.ts | 13 +- src/app/test/mocks/database.ts | 6 +- src/app/test/utils/context.test.ts | 39 ++-- src/app/test/utils/draft.test.ts | 5 +- src/app/test/utils/tree.test.ts | 8 +- src/module/src/module.ts | 38 +++- src/module/src/runtime/host.ts | 37 ++- src/module/src/types/global.d.ts | 6 + 35 files changed, 707 insertions(+), 234 deletions(-) create mode 100644 src/app/src/components/panel/base/PanelBaseBody.vue create mode 100644 src/app/src/components/panel/base/PanelBaseBodyEditor.vue rename src/app/src/components/panel/{content/PanelContentTree.vue => base/PanelBaseBodyTree.vue} (77%) create mode 100644 src/app/src/components/panel/base/PanelBaseSubHeader.vue delete mode 100644 src/app/src/components/panel/content/PanelContent.vue create mode 100644 src/app/src/components/panel/media/PanelMediaEditor.vue rename src/app/src/composables/{useDraftFiles.ts => useDraftDocuments.ts} (71%) create mode 100644 src/app/src/composables/useDraftMedias.ts create mode 100644 src/app/src/composables/useMediaTree.ts create mode 100644 src/app/src/types/item.ts create mode 100644 src/app/src/types/media.ts diff --git a/src/app/src/App.vue b/src/app/src/App.vue index fbc6f6c0..400c8b57 100644 --- a/src/app/src/App.vue +++ b/src/app/src/App.vue @@ -1,13 +1,10 @@ + + diff --git a/src/app/src/components/panel/base/PanelBaseBodyEditor.vue b/src/app/src/components/panel/base/PanelBaseBodyEditor.vue new file mode 100644 index 00000000..88327984 --- /dev/null +++ b/src/app/src/components/panel/base/PanelBaseBodyEditor.vue @@ -0,0 +1,25 @@ + + + diff --git a/src/app/src/components/panel/content/PanelContentTree.vue b/src/app/src/components/panel/base/PanelBaseBodyTree.vue similarity index 77% rename from src/app/src/components/panel/content/PanelContentTree.vue rename to src/app/src/components/panel/base/PanelBaseBodyTree.vue index 3e608a78..6a5a0a66 100644 --- a/src/app/src/components/panel/content/PanelContentTree.vue +++ b/src/app/src/components/panel/base/PanelBaseBodyTree.vue @@ -1,9 +1,11 @@ diff --git a/src/app/src/components/panel/base/PanelBaseSubHeader.vue b/src/app/src/components/panel/base/PanelBaseSubHeader.vue new file mode 100644 index 00000000..07938fb5 --- /dev/null +++ b/src/app/src/components/panel/base/PanelBaseSubHeader.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/app/src/components/panel/content/PanelContent.vue b/src/app/src/components/panel/content/PanelContent.vue deleted file mode 100644 index 713bc86d..00000000 --- a/src/app/src/components/panel/content/PanelContent.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/src/app/src/components/panel/content/editor/PanelContentEditor.vue b/src/app/src/components/panel/content/editor/PanelContentEditor.vue index ba8833fc..c5d214c3 100644 --- a/src/app/src/components/panel/content/editor/PanelContentEditor.vue +++ b/src/app/src/components/panel/content/editor/PanelContentEditor.vue @@ -2,17 +2,17 @@ import { computed, type PropType, toRaw } from 'vue' import { decompressTree } from '@nuxt/content/runtime' import type { MarkdownRoot } from '@nuxt/content' -import type { DatabasePageItem, DraftFileItem } from '../../../../types' +import type { DatabasePageItem, DraftItem } from '../../../../types' import { useStudio } from '../../../../composables/useStudio' const props = defineProps({ draftItem: { - type: Object as PropType, + type: Object as PropType, required: true, }, }) -const { draftFiles } = useStudio() +const { draftDocuments } = useStudio() const document = computed({ get() { @@ -20,13 +20,13 @@ const document = computed({ return {} as DatabasePageItem } - const dbItem = props.draftItem.document as DatabasePageItem + const dbItem = props.draftItem.modified as DatabasePageItem let result: DatabasePageItem // TODO: check mdcRoot and markdownRoot types with Ahad if (dbItem.body?.type === 'minimark') { result = { - ...props.draftItem.document as DatabasePageItem, + ...props.draftItem.modified as DatabasePageItem, // @ts-expect-error todo fix MarkdownRoot/MDCRoot conversion in MDC module body: decompressTree(dbItem.body) as MarkdownRoot, } @@ -38,7 +38,7 @@ const document = computed({ return result }, set(value) { - draftFiles.update(props.draftItem.id, { + draftDocuments.update(props.draftItem.id, { ...toRaw(document.value as DatabasePageItem), ...toRaw(value), }) diff --git a/src/app/src/components/panel/content/editor/PanelContentEditorCode.vue b/src/app/src/components/panel/content/editor/PanelContentEditorCode.vue index 7b5a335f..2575ccd1 100644 --- a/src/app/src/components/panel/content/editor/PanelContentEditorCode.vue +++ b/src/app/src/components/panel/content/editor/PanelContentEditorCode.vue @@ -1,13 +1,13 @@ + + diff --git a/src/app/src/components/shared/item/ItemBreadcrumb.vue b/src/app/src/components/shared/item/ItemBreadcrumb.vue index 6890482c..651129dc 100644 --- a/src/app/src/components/shared/item/ItemBreadcrumb.vue +++ b/src/app/src/components/shared/item/ItemBreadcrumb.vue @@ -2,13 +2,13 @@ import type { BreadcrumbItem } from '@nuxt/ui/components/Breadcrumb.vue.d.ts' import type { DropdownMenuItem } from '@nuxt/ui/components/DropdownMenu.vue.d.ts' import { computed, type PropType, unref } from 'vue' -import type { TreeItem } from '../../../types' +import { StudioFeature, type TreeItem } from '../../../types' import { useStudio } from '../../../composables/useStudio' import { findParentFromId, ROOT_ITEM } from '../../../utils/tree' import { FEATURE_DISPLAY_MAP } from '../../../utils/context' import { DraftStatus } from '../../../types/draft' -const { tree: treeApi, context } = useStudio() +const { documentTree, mediaTree, context } = useStudio() const props = defineProps({ currentItem: { @@ -21,11 +21,14 @@ const props = defineProps({ }, }) +const treeApi = computed(() => context.feature.value === StudioFeature.Content ? documentTree : mediaTree) + const items = computed(() => { const rootItem = { label: FEATURE_DISPLAY_MAP[context.feature.value as keyof typeof FEATURE_DISPLAY_MAP], onClick: () => { - treeApi.select(ROOT_ITEM) + // TODO: update for ROOT_DOCUMENT_ITEM and ROOT_MEDIA_ITEM + treeApi.value.select(ROOT_ITEM) }, } @@ -41,7 +44,7 @@ const items = computed(() => { breadcrumbItems.unshift({ label: currentTreeItem.name, onClick: async () => { - await treeApi.select(itemToSelect) + await treeApi.value.select(itemToSelect) }, }) diff --git a/src/app/src/composables/useContext.ts b/src/app/src/composables/useContext.ts index cc608e52..445c09b3 100644 --- a/src/app/src/composables/useContext.ts +++ b/src/app/src/composables/useContext.ts @@ -3,14 +3,16 @@ import { computed, ref } from 'vue' import type { useUi } from './useUi' import { type CreateFileParams, type StudioHost, type StudioAction, type TreeItem, StudioItemActionId, type ActionHandlerParams } from '../types' import { oneStepActions, STUDIO_ITEM_ACTION_DEFINITIONS, twoStepActions } from '../utils/context' -import type { useDraftFiles } from './useDraftFiles' +import type { useDraftDocuments } from './useDraftDocuments' import { useModal } from './useModal' import type { useTree } from './useTree' +import type { useDraftMedias } from './useDraftMedias' export const useContext = createSharedComposable(( host: StudioHost, ui: ReturnType, - draftFiles: ReturnType, + draftDocuments: ReturnType, + draftMedias: ReturnType, tree: ReturnType, ) => { const modal = useModal() @@ -52,13 +54,13 @@ export const useContext = createSharedComposable(( [StudioItemActionId.CreateFolder]: async (args: string) => { alert(`create folder ${args}`) }, - [StudioItemActionId.CreateFile]: async ({ fsPath, routePath, content }: CreateFileParams) => { + [StudioItemActionId.CreateDocument]: async ({ fsPath, routePath, content }: CreateFileParams) => { const document = await host.document.create(fsPath, routePath, content) - const draftItem = await draftFiles.create(document) + const draftItem = await draftDocuments.create(document) tree.selectItemById(draftItem.id) }, [StudioItemActionId.RevertItem]: async (id: string) => { - modal.openConfirmActionModal(id, StudioItemActionId.RevertItem, () => draftFiles.revert(id)) + modal.openConfirmActionModal(id, StudioItemActionId.RevertItem, () => draftDocuments.revert(id)) }, [StudioItemActionId.RenameItem]: async ({ path, file }: { path: string, file: TreeItem }) => { alert(`rename file ${path} ${file.name}`) diff --git a/src/app/src/composables/useDraftFiles.ts b/src/app/src/composables/useDraftDocuments.ts similarity index 71% rename from src/app/src/composables/useDraftFiles.ts rename to src/app/src/composables/useDraftDocuments.ts index 35ac31fe..ad80ac30 100644 --- a/src/app/src/composables/useDraftFiles.ts +++ b/src/app/src/composables/useDraftDocuments.ts @@ -1,6 +1,7 @@ +import { createStorage } from 'unstorage' +import indexedDbDriver from 'unstorage/drivers/indexedb' import { ref } from 'vue' -import type { StorageValue, Storage } from 'unstorage' -import type { DatabaseItem, DraftFileItem, StudioHost, GithubFile, DatabasePageItem } from '../types' +import type { DatabaseItem, DraftItem, StudioHost, GithubFile, DatabasePageItem } from '../types' import { DraftStatus } from '../types/draft' import type { useGit } from './useGit' import { generateContentFromDocument } from '../utils/content' @@ -8,9 +9,15 @@ import { getDraftStatus } from '../utils/draft' import { createSharedComposable } from '@vueuse/core' import { useHooks } from './useHooks' -export const useDraftFiles = createSharedComposable((host: StudioHost, git: ReturnType, storage: Storage) => { - const list = ref([]) - const current = ref(null) +const storage = createStorage({ + driver: indexedDbDriver({ + storeName: 'nuxt-content-studio-documents', + }), +}) + +export const useDraftDocuments = createSharedComposable((host: StudioHost, git: ReturnType) => { + const list = ref[]>([]) + const current = ref | null>(null) const hooks = useHooks() @@ -19,7 +26,7 @@ export const useDraftFiles = createSharedComposable((host: StudioHost, git: Retu if (item && generateContent) { return { ...item, - content: await generateContentFromDocument(item!.document as DatabasePageItem) || '', + content: await generateContentFromDocument(item!.modified as DatabasePageItem) || '', } } return item @@ -32,15 +39,15 @@ export const useDraftFiles = createSharedComposable((host: StudioHost, git: Retu } const fsPath = host.document.getFileSystemPath(document.id) - const originalGithubFile = await git.fetchFile(fsPath, { cached: true }) as GithubFile + const githubFile = await git.fetchFile(fsPath, { cached: true }) as GithubFile - const item: DraftFileItem = { + const item: DraftItem = { id: document.id, fsPath, - originalDatabaseItem: document, - originalGithubFile, + original: document, + githubFile, status, - document, + modified: document, } await storage.setItem(document.id, item) @@ -59,15 +66,15 @@ export const useDraftFiles = createSharedComposable((host: StudioHost, git: Retu } const oldStatus = existingItem.status - existingItem.status = getDraftStatus(document, existingItem.originalDatabaseItem) - existingItem.document = document + existingItem.status = getDraftStatus(document, existingItem.original) + existingItem.modified = document await storage.setItem(id, existingItem) list.value = list.value.map(item => item.id === id ? existingItem : item) // Upsert document in database - await host.document.upsert(id, existingItem.document) + await host.document.upsert(id, existingItem.modified) // Rerender host app host.app.requestRerender() @@ -81,7 +88,7 @@ export const useDraftFiles = createSharedComposable((host: StudioHost, git: Retu } async function remove(id: string) { - const item = await storage.getItem(id) as DraftFileItem + const item = await storage.getItem(id) as DraftItem const fsPath = host.document.getFileSystemPath(id) if (item) { @@ -90,30 +97,30 @@ export const useDraftFiles = createSharedComposable((host: StudioHost, git: Retu await storage.removeItem(id) await host.document.delete(id) - if (item.originalDatabaseItem) { - const deleteDraft: DraftFileItem = { + if (item.original) { + const deleteDraft: DraftItem = { id, fsPath: item.fsPath, status: DraftStatus.Deleted, - originalDatabaseItem: item.originalDatabaseItem, - originalGithubFile: item.originalGithubFile, + original: item.original, + githubFile: item.githubFile, } await storage.setItem(id, deleteDraft) - await host.document.upsert(id, item.originalDatabaseItem!) + await host.document.upsert(id, item.original!) } } else { // Fetch github file before creating draft to detect non deployed changes - const originalGithubFile = await git.fetchFile(fsPath, { cached: true }) as GithubFile - const originalDatabaseItem = await host.document.get(id) + const githubFile = await git.fetchFile(fsPath, { cached: true }) as GithubFile + const original = await host.document.get(id) - const deleteItem: DraftFileItem = { + const deleteItem: DraftItem = { id, fsPath, status: DraftStatus.Deleted, - originalDatabaseItem, - originalGithubFile, + original, + githubFile, } await storage.setItem(id, deleteItem) @@ -137,9 +144,9 @@ export const useDraftFiles = createSharedComposable((host: StudioHost, git: Retu list.value = list.value.filter(item => item.id !== id) } else { - await host.document.upsert(id, existingItem.originalDatabaseItem!) + await host.document.upsert(id, existingItem.original!) existingItem.status = DraftStatus.Opened - existingItem.document = existingItem.originalDatabaseItem + existingItem.modified = existingItem.original await storage.setItem(id, existingItem) } @@ -151,8 +158,8 @@ export const useDraftFiles = createSharedComposable((host: StudioHost, git: Retu async function revertAll() { await storage.clear() for (const item of list.value) { - if (item.originalDatabaseItem) { - await host.document.upsert(item.id, item.originalDatabaseItem) + if (item.original) { + await host.document.upsert(item.id, item.original) } else if (item.status === DraftStatus.Created) { await host.document.delete(item.id) @@ -165,7 +172,7 @@ export const useDraftFiles = createSharedComposable((host: StudioHost, git: Retu async function load() { const storedList = await storage.getKeys().then(async (keys) => { return Promise.all(keys.map(async (key) => { - const item = await storage.getItem(key) as DraftFileItem + const item = await storage.getItem(key) as DraftItem if (item.status === DraftStatus.Opened) { await storage.removeItem(key) return null @@ -174,7 +181,7 @@ export const useDraftFiles = createSharedComposable((host: StudioHost, git: Retu })) }) - list.value = storedList.filter(Boolean) as DraftFileItem[] + list.value = storedList.filter(Boolean) as DraftItem[] // Upsert/Delete draft files in database await Promise.all(list.value.map(async (draftItem) => { @@ -182,7 +189,7 @@ export const useDraftFiles = createSharedComposable((host: StudioHost, git: Retu await host.document.delete(draftItem.id) } else { - await host.document.upsert(draftItem.id, draftItem.document!) + await host.document.upsert(draftItem.id, draftItem.modified!) } })) @@ -191,7 +198,7 @@ export const useDraftFiles = createSharedComposable((host: StudioHost, git: Retu await hooks.callHook('studio:draft:updated') } - function select(draftItem: DraftFileItem | null) { + function select(draftItem: DraftItem | null) { current.value = draftItem } diff --git a/src/app/src/composables/useDraftMedias.ts b/src/app/src/composables/useDraftMedias.ts new file mode 100644 index 00000000..afb5f6d7 --- /dev/null +++ b/src/app/src/composables/useDraftMedias.ts @@ -0,0 +1,215 @@ +import { ref } from 'vue' +import { createStorage } from 'unstorage' +import indexedDbDriver from 'unstorage/drivers/indexedb' +import type { DraftItem, StudioHost, GithubFile, MediaItem } from '../types' +import { DraftStatus } from '../types/draft' +import type { useGit } from './useGit' +import { getDraftStatus } from '../utils/draft' +import { createSharedComposable } from '@vueuse/core' +import { useHooks } from './useHooks' + +const storage = createStorage({ + driver: indexedDbDriver({ + storeName: 'nuxt-content-studio-medias', + }), +}) + +export const useDraftMedias = createSharedComposable((host: StudioHost, git: ReturnType) => { + const list = ref([]) + const current = ref(null) + + const hooks = useHooks() + + async function get(id: string) { + const item = list.value.find(item => item.id === id) + return item + } + + async function create(media: MediaItem, status: DraftStatus = DraftStatus.Created) { + const existingItem = list.value.find(item => item.id === media.id) + if (existingItem) { + throw new Error(`Draft file already exists for document ${media.id}`) + } + + const fsPath = host.media.getFileSystemPath(media.id) + const githubFile = await git.fetchFile(fsPath, { cached: true }) as GithubFile + + const item: DraftItem = { + id: media.id, + fsPath, + original: media, + githubFile, + status, + modified: media, + } + + await storage.setItem(media.id, item) + + list.value.push(item) + + await hooks.callHook('studio:draft:updated') + + return item + } + + async function update(id: string, media: MediaItem) { + const existingItem = list.value.find(item => item.id === id) + if (!existingItem) { + throw new Error(`Draft file not found for document ${id}`) + } + + const oldStatus = existingItem.status + existingItem.status = getDraftStatus(media, existingItem.original) + existingItem.modified = media + + await storage.setItem(id, existingItem) + + list.value = list.value.map(item => item.id === id ? existingItem : item) + + // Upsert document in database + await host.media.upsert(id, existingItem.modified!) + + // Rerender host app + host.app.requestRerender() + + // Trigger hook to warn that draft list has changed + if (existingItem.status !== oldStatus) { + await hooks.callHook('studio:draft:updated') + } + + return existingItem + } + + async function remove(id: string) { + const item = await storage.getItem(id) as DraftItem + const fsPath = host.media.getFileSystemPath(id) + + if (item) { + if (item.status === DraftStatus.Deleted) return + + await storage.removeItem(id) + await host.media.delete(id) + } + else { + // Fetch github file before creating draft to detect non deployed changes + const githubFile = await git.fetchFile(fsPath, { cached: true }) as GithubFile + const original = await host.media.get(id) + + const deleteItem: DraftItem = { + id, + fsPath, + status: DraftStatus.Deleted, + original, + githubFile, + } + + await storage.setItem(id, deleteItem) + + await host.media.delete(id) + } + + list.value = list.value.filter(item => item.id !== id) + host.app.requestRerender() + } + + async function revert(id: string) { + const existingItem = list.value.find(item => item.id === id) + if (!existingItem) { + return + } + + if (existingItem.status === DraftStatus.Created) { + await host.media.delete(id) + await storage.removeItem(id) + list.value = list.value.filter(item => item.id !== id) + } + else { + await host.media.upsert(id, existingItem.original!) + existingItem.status = DraftStatus.Opened + existingItem.modified = existingItem.original + await storage.setItem(id, existingItem) + } + + await hooks.callHook('studio:draft:updated') + + host.app.requestRerender() + } + + async function revertAll() { + await storage.clear() + for (const item of list.value) { + if (item.original) { + await host.media.upsert(item.id, item.original) + } + else if (item.status === DraftStatus.Created) { + await host.media.delete(item.id) + } + } + list.value = [] + host.app.requestRerender() + } + + async function load() { + const storedList = await storage.getKeys().then(async (keys) => { + return Promise.all(keys.map(async (key) => { + const item = await storage.getItem(key) as DraftItem + if (item.status === DraftStatus.Opened) { + await storage.removeItem(key) + return null + } + return item + })) + }) + + list.value = storedList.filter(Boolean) as DraftItem[] + + // Upsert/Delete draft files in database + await Promise.all(list.value.map(async (draftItem) => { + if (draftItem.status === DraftStatus.Deleted) { + await host.media.delete(draftItem.id) + } + else { + await host.media.upsert(draftItem.id, draftItem.modified!) + } + })) + + host.app.requestRerender() + + await hooks.callHook('studio:draft:updated') + } + + function select(draftItem: DraftItem | null) { + current.value = draftItem + console.log('select', draftItem) + } + + async function selectById(id: string) { + const existingItem = list.value.find(item => item.id === id) + if (existingItem) { + select(existingItem) + return + } + + const dbItem = await host.media.get(id) + if (!dbItem) { + throw new Error(`Cannot select item: no corresponding database entry found for id ${id}`) + } + + const draftItem = await create(dbItem, DraftStatus.Opened) + select(draftItem) + } + + return { + get, + create, + update, + remove, + revert, + revertAll, + list, + load, + current, + select, + selectById, + } +}) diff --git a/src/app/src/composables/useMediaTree.ts b/src/app/src/composables/useMediaTree.ts new file mode 100644 index 00000000..74956c5a --- /dev/null +++ b/src/app/src/composables/useMediaTree.ts @@ -0,0 +1,93 @@ +import type { StudioHost, TreeItem } from '../types' +import { ref, computed } from 'vue' +import type { useDraftDocuments } from './useDraftDocuments' +import { buildTree, findItemFromId, findItemFromRoute, ROOT_ITEM } from '../utils/tree' +import type { RouteLocationNormalized } from 'vue-router' +import { createSharedComposable } from '@vueuse/core' +import { useHooks } from './useHooks' + +export const useTree = createSharedComposable((host: StudioHost, draftDocuments: ReturnType) => { + const hooks = useHooks() + + const tree = ref([]) + const currentItem = ref(ROOT_ITEM) + + const currentTree = computed(() => { + if (currentItem.value.id === ROOT_ITEM.id) { + return tree.value + } + + let subTree = tree.value + const idSegments = currentItem.value.id.split('/').filter(Boolean) + for (let i = 0; i < idSegments.length; i++) { + const id = idSegments.slice(0, i + 1).join('/') + const file = subTree.find(item => item.id === id) as TreeItem + if (file) { + subTree = file.children! + } + } + + return subTree + }) + + // const parentItem = computed(() => { + // if (currentItem.value.id === ROOT_ITEM.id) return null + + // const parent = findParentFromId(tree.value, currentItem.value.id) + // return parent || ROOT_ITEM + // }) + + async function select(item: TreeItem) { + currentItem.value = item || ROOT_ITEM + if (item?.type === 'file') { + host.app.navigateTo(item.routePath!) + await draftDocuments.selectById(item.id) + } + else { + draftDocuments.select(null) + } + } + + async function selectByRoute(route: RouteLocationNormalized) { + const item = findItemFromRoute(tree.value, route) + + if (!item || item.id === currentItem.value.id) return + + select(item) + } + + async function selectItemById(id: string) { + const treeItem = findItemFromId(tree.value, id) + + if (!treeItem || treeItem.id === currentItem.value.id) return + + select(treeItem) + } + + hooks.hook('studio:draft:updated', async () => { + const list = await host.document.list() + const listWithFsPath = list.map((item) => { + const fsPath = host.document.getFileSystemPath(item.id) + return { + ...item, + fsPath, + } + }) + + // Trigger tree rebuild to update files status + tree.value = buildTree(listWithFsPath, draftDocuments.list.value) + + // Reselect current item to update status + select(findItemFromId(tree.value, currentItem.value.id)!) + }) + + return { + root: tree, + current: currentTree, + currentItem, + // parentItem, + select, + selectByRoute, + selectItemById, + } +}) diff --git a/src/app/src/composables/useStudio.ts b/src/app/src/composables/useStudio.ts index db996a6b..0f41c54f 100644 --- a/src/app/src/composables/useStudio.ts +++ b/src/app/src/composables/useStudio.ts @@ -1,19 +1,13 @@ -import { createStorage } from 'unstorage' -import indexedDbDriver from 'unstorage/drivers/indexedb' import { useGit } from './useGit' import { useUi } from './useUi' import { useContext } from './useContext' -import { useDraftFiles } from './useDraftFiles' +import { useDraftDocuments } from './useDraftDocuments' +import { useDraftMedias } from './useDraftMedias' import { ref } from 'vue' import { useTree } from './useTree' import { createSharedComposable } from '@vueuse/core' import type { RouteLocationNormalized } from 'vue-router' - -const storage = createStorage({ - driver: indexedDbDriver({ - storeName: 'nuxt-content-preview', - }), -}) +import { StudioFeature } from '../types' export const useStudio = createSharedComposable(() => { const host = window.useStudioHost() @@ -28,17 +22,21 @@ export const useStudio = createSharedComposable(() => { const isReady = ref(false) const ui = useUi(host) - const draftFiles = useDraftFiles(host, git, storage) - const tree = useTree(host, draftFiles) - const context = useContext(host, ui, draftFiles, tree) + const draftDocuments = useDraftDocuments(host, git) + const documentTree = useTree(StudioFeature.Content, host, draftDocuments) + + const draftMedias = useDraftMedias(host, git) + const mediaTree = useTree(StudioFeature.Media, host, draftMedias) + + const context = useContext(host, ui, draftDocuments, draftMedias, documentTree) host.on.mounted(async () => { - await draftFiles.load() + await draftDocuments.load() host.app.requestRerender() isReady.value = true host.on.routeChange((to: RouteLocationNormalized, _from: RouteLocationNormalized) => { - tree.selectByRoute(to) + documentTree.selectByRoute(to) // setTimeout(() => { // detectActiveDocuments() // }, 100) @@ -69,15 +67,17 @@ export const useStudio = createSharedComposable(() => { git, ui, context, - draftFiles, - tree, + draftDocuments, + draftMedias, + documentTree, + mediaTree, // draftMedia: { - // get -> DraftMediaItem + // get -> DraftItem // upsert // remove // revert // move - // list -> DraftMediaItem[] + // list -> DraftItem[] // revertAll // } // media: { diff --git a/src/app/src/composables/useTree.ts b/src/app/src/composables/useTree.ts index 9a6de6e1..457059e7 100644 --- a/src/app/src/composables/useTree.ts +++ b/src/app/src/composables/useTree.ts @@ -1,12 +1,12 @@ -import type { StudioHost, TreeItem } from '../types' +import { StudioFeature, type StudioHost, type TreeItem } from '../types' import { ref, computed } from 'vue' -import type { useDraftFiles } from './useDraftFiles' +import type { useDraftDocuments } from './useDraftDocuments' +import type { useDraftMedias } from './useDraftMedias' import { buildTree, findItemFromId, findItemFromRoute, ROOT_ITEM } from '../utils/tree' import type { RouteLocationNormalized } from 'vue-router' -import { createSharedComposable } from '@vueuse/core' import { useHooks } from './useHooks' -export const useTree = createSharedComposable((host: StudioHost, draftFiles: ReturnType) => { +export const useTree = (type: StudioFeature, host: StudioHost, draft: ReturnType) => { const hooks = useHooks() const tree = ref([]) @@ -40,11 +40,14 @@ export const useTree = createSharedComposable((host: StudioHost, draftFiles: Ret async function select(item: TreeItem) { currentItem.value = item || ROOT_ITEM if (item?.type === 'file') { - host.app.navigateTo(item.routePath!) - await draftFiles.selectById(item.id) + if (type === StudioFeature.Content) { + host.app.navigateTo(item.routePath!) + } + + await draft.selectById(item.id) } else { - draftFiles.select(null) + draft.select(null) } } @@ -65,9 +68,12 @@ export const useTree = createSharedComposable((host: StudioHost, draftFiles: Ret } hooks.hook('studio:draft:updated', async () => { - const list = await host.document.list() + const hostList = type === 'content' ? host.document : host.media + const hostGetFileSystemPath = type === 'content' ? host.document.getFileSystemPath : host.media.getFileSystemPath + const list = await hostList.list() + const listWithFsPath = list.map((item) => { - const fsPath = host.document.getFileSystemPath(item.id) + const fsPath = hostGetFileSystemPath(item.id) return { ...item, fsPath, @@ -75,7 +81,7 @@ export const useTree = createSharedComposable((host: StudioHost, draftFiles: Ret }) // Trigger tree rebuild to update files status - tree.value = buildTree(listWithFsPath, draftFiles.list.value) + tree.value = buildTree(listWithFsPath, draft.list.value) // Reselect current item to update status select(findItemFromId(tree.value, currentItem.value.id)!) @@ -90,4 +96,4 @@ export const useTree = createSharedComposable((host: StudioHost, draftFiles: Ret selectByRoute, selectItemById, } -}) +} diff --git a/src/app/src/composables/useUi.ts b/src/app/src/composables/useUi.ts index a205860b..f13ca86f 100644 --- a/src/app/src/composables/useUi.ts +++ b/src/app/src/composables/useUi.ts @@ -6,7 +6,6 @@ export const useUi = createSharedComposable((host: StudioHost) => { const panels = reactive({ [StudioFeature.Content]: false, [StudioFeature.Media]: false, - [StudioFeature.Config]: false, }) const isPanelOpen = computed(() => Object.values(panels).some(value => value)) diff --git a/src/app/src/types/context.ts b/src/app/src/types/context.ts index f41e1df3..e31b836f 100644 --- a/src/app/src/types/context.ts +++ b/src/app/src/types/context.ts @@ -3,12 +3,11 @@ import type { TreeItem } from './tree' export enum StudioFeature { Content = 'content', Media = 'media', - Config = 'config', } export enum StudioItemActionId { CreateFolder = 'create-folder', - CreateFile = 'create-file', + CreateDocument = 'create-document', RevertItem = 'revert-item', RenameItem = 'rename-item', DeleteItem = 'delete-item', @@ -36,7 +35,7 @@ export interface RenameFileParams { export type ActionHandlerParams = { [StudioItemActionId.CreateFolder]: string - [StudioItemActionId.CreateFile]: CreateFileParams + [StudioItemActionId.CreateDocument]: CreateFileParams [StudioItemActionId.RevertItem]: string [StudioItemActionId.RenameItem]: RenameFileParams [StudioItemActionId.DeleteItem]: string diff --git a/src/app/src/types/database.ts b/src/app/src/types/database.ts index d26fda29..b3076383 100644 --- a/src/app/src/types/database.ts +++ b/src/app/src/types/database.ts @@ -1,13 +1,15 @@ import type { CollectionItemBase, PageCollectionItemBase, DataCollectionItemBase } from '@nuxt/content' +import type { BaseItem } from './item' -export interface DatabaseItem extends CollectionItemBase { +export interface DatabaseItem extends CollectionItemBase, BaseItem { [key: string]: unknown } -export interface DatabasePageItem extends PageCollectionItemBase { +export interface DatabasePageItem extends PageCollectionItemBase, BaseItem { + path: string [key: string]: unknown } -export interface DatabaseDataItem extends DataCollectionItemBase { +export interface DatabaseDataItem extends DataCollectionItemBase, BaseItem { [key: string]: unknown } diff --git a/src/app/src/types/draft.ts b/src/app/src/types/draft.ts index ab4f05e3..edadf53f 100644 --- a/src/app/src/types/draft.ts +++ b/src/app/src/types/draft.ts @@ -1,5 +1,6 @@ import type { GithubFile } from './github' import type { DatabaseItem } from './database' +import type { MediaItem } from './media' export enum DraftStatus { Deleted = 'deleted', @@ -9,27 +10,42 @@ export enum DraftStatus { Opened = 'opened', } -export interface DraftItem { +export interface DraftItem { id: string // nuxt/content id fsPath: string // file path in content directory status: DraftStatus // status -} -export interface DraftFileItem extends DraftItem { - originalDatabaseItem?: DatabaseItem // original collection document saved in db - originalGithubFile?: GithubFile // file fetched on gh - content?: string // Drafted raw markdown content - document?: DatabaseItem // Drafted parsed AST (body, frontmatter...) + githubFile: GithubFile // file fetched on gh + original?: T + modified?: T + /** + * - String: Markdown for docuemnts + * - Buffer: Media content + */ + raw?: string | Buffer } -export interface DraftMediaItem extends DraftItem { - oldPath?: string // Old path in public directory (used for revert a renamed file) - content?: string // Base64 value - url?: string // Public gh url +// export interface DraftItem extends DraftItem { +// originalDatabaseItem?: DatabaseItem // original collection document saved in db +// originalGithubFile?: GithubFile // file fetched on gh +// content?: string // Drafted raw markdown content +// document?: DatabaseItem // Drafted parsed AST (body, frontmatter...) +// } - // Image metas - width?: number - height?: number - size?: number - mimeType?: string -} +// export interface DraftItem extends DraftItem { +// originalMediaItem?: MediaItem // original collection document saved in db +// originalGithubFile?: GithubFile // file fetched on gh +// content?: Buffer // Drafted raw media content +// media?: MediaItem // Drafted parsed AST (body, frontmatter...) + +// // +// oldPath?: string // Old path in public directory (used for revert a renamed file) +// // content?: string // Base64 value +// url?: string // Public gh url + +// // Image metas +// width?: number +// height?: number +// size?: number +// mimeType?: string +// } diff --git a/src/app/src/types/index.ts b/src/app/src/types/index.ts index c1ec5712..1f1bdb5c 100644 --- a/src/app/src/types/index.ts +++ b/src/app/src/types/index.ts @@ -1,9 +1,12 @@ import type { StudioUser } from './user' import type { DatabaseItem } from './database' import type { RouteLocationNormalized } from 'vue-router' +import type { MediaItem } from './media' +export * from './item' export * from './draft' export * from './database' +export * from './media' export * from './user' export * from './tree' export * from './github' @@ -32,6 +35,14 @@ export interface StudioHost { delete: (id: string) => Promise detectActives: () => Array<{ id: string, title: string }> } + media: { + get: (id: string) => Promise + getFileSystemPath: (id: string) => string + list: () => Promise + upsert: (id: string, upsertedDocument: MediaItem) => Promise + create: (fsPath: string, routePath: string, content: string) => Promise + delete: (id: string) => Promise + } user: { get: () => StudioUser } diff --git a/src/app/src/types/item.ts b/src/app/src/types/item.ts new file mode 100644 index 00000000..8c0325fb --- /dev/null +++ b/src/app/src/types/item.ts @@ -0,0 +1,6 @@ +export interface BaseItem { + id: string + extension: string + stem: string + path?: string +} diff --git a/src/app/src/types/media.ts b/src/app/src/types/media.ts new file mode 100644 index 00000000..99d7109b --- /dev/null +++ b/src/app/src/types/media.ts @@ -0,0 +1,5 @@ +import type { BaseItem } from './item' + +export interface MediaItem extends BaseItem { + [key: string]: unknown +} diff --git a/src/app/src/utils/context.ts b/src/app/src/utils/context.ts index 35dd7983..1ef08053 100644 --- a/src/app/src/utils/context.ts +++ b/src/app/src/utils/context.ts @@ -5,11 +5,10 @@ import { StudioItemActionId } from '../types/context' export const FEATURE_DISPLAY_MAP = { [StudioFeature.Content]: 'Content files', [StudioFeature.Media]: 'Media library', - [StudioFeature.Config]: 'Application configuration', } as const export const oneStepActions: StudioItemActionId[] = [StudioItemActionId.RevertItem, StudioItemActionId.DeleteItem, StudioItemActionId.DuplicateItem] -export const twoStepActions: StudioItemActionId[] = [StudioItemActionId.CreateFile, StudioItemActionId.CreateFolder, StudioItemActionId.RenameItem] +export const twoStepActions: StudioItemActionId[] = [StudioItemActionId.CreateDocument, StudioItemActionId.CreateFolder, StudioItemActionId.RenameItem] export const STUDIO_ITEM_ACTION_DEFINITIONS: StudioAction[] = [ { @@ -19,7 +18,7 @@ export const STUDIO_ITEM_ACTION_DEFINITIONS: StudioAction[] = [ tooltip: 'Create a new folder', }, { - id: StudioItemActionId.CreateFile, + id: StudioItemActionId.CreateDocument, label: 'Create file', icon: 'i-lucide-file-plus', tooltip: 'Create a new file', @@ -64,7 +63,7 @@ export function computeActionItems(itemActions: StudioAction[], item?: TreeItem // Item type filtering switch (item.type) { case 'file': - forbiddenActions.push(StudioItemActionId.CreateFolder, StudioItemActionId.CreateFile) + forbiddenActions.push(StudioItemActionId.CreateFolder, StudioItemActionId.CreateDocument) break case 'directory': forbiddenActions.push(StudioItemActionId.DuplicateItem) diff --git a/src/app/src/utils/draft.ts b/src/app/src/utils/draft.ts index 99d67051..1231fcf3 100644 --- a/src/app/src/utils/draft.ts +++ b/src/app/src/utils/draft.ts @@ -1,6 +1,4 @@ -import type { DatabaseItem, DatabasePageItem } from '../types/database' -import { DraftStatus } from '../types/draft' -import { ContentFileExtension } from '../types/content' +import { type BaseItem, type DatabasePageItem, ContentFileExtension, DraftStatus } from '../types' import { stringify } from 'minimark/stringify' export const COLOR_STATUS_MAP: { [key in DraftStatus]?: string } = { @@ -19,18 +17,18 @@ export const COLOR_UI_STATUS_MAP: { [key in DraftStatus]?: string } = { [DraftStatus.Opened]: 'neutral', } as const -export function getDraftStatus(draftedDocument: DatabaseItem, originalDatabaseItem: DatabaseItem | undefined) { - if (!originalDatabaseItem) { +export function getDraftStatus(modified: BaseItem, original: BaseItem | undefined) { + if (!original) { return DraftStatus.Created } - if (originalDatabaseItem.extension === ContentFileExtension.Markdown) { - if (!isEqual(originalDatabaseItem as DatabasePageItem, draftedDocument as DatabasePageItem)) { + if (original.extension === ContentFileExtension.Markdown) { + if (!isEqual(original as DatabasePageItem, modified as DatabasePageItem)) { return DraftStatus.Updated } } else { - if (JSON.stringify(originalDatabaseItem) !== JSON.stringify(draftedDocument)) { + if (JSON.stringify(original) !== JSON.stringify(modified)) { return DraftStatus.Updated } } @@ -40,7 +38,7 @@ export function getDraftStatus(draftedDocument: DatabaseItem, originalDatabaseIt function isEqual(document1: DatabasePageItem, document2: DatabasePageItem) { function removeLastStyle(document: DatabasePageItem) { - if (document.body?.value[document.body?.value.length - 1][0] === 'style') { + if (document.body?.value[document.body?.value.length - 1]?.[0] === 'style') { return { ...document, body: { ...document.body, value: document.body?.value.slice(0, -1) } } } return document diff --git a/src/app/src/utils/tree.ts b/src/app/src/utils/tree.ts index 1a129b92..ac02e53c 100644 --- a/src/app/src/utils/tree.ts +++ b/src/app/src/utils/tree.ts @@ -1,23 +1,24 @@ -import { DraftStatus, type DatabaseItem, type DraftFileItem, type TreeItem } from '../types' +import { DraftStatus, type DraftItem, type TreeItem } from '../types' import { withLeadingSlash } from 'ufo' import { stripNumericPrefix } from './string' import type { RouteLocationNormalized } from 'vue-router' +import type { BaseItem } from '../types/item' export const ROOT_ITEM: TreeItem = { id: 'root', name: 'content', fsPath: '/', type: 'root' } -export function buildTree(dbItems: (DatabaseItem & { fsPath: string })[], draftList: DraftFileItem[] | null): +export function buildTree(dbItems: ((BaseItem) & { fsPath: string })[], draftList: DraftItem[] | null): TreeItem[] { const tree: TreeItem[] = [] const directoryMap = new Map() for (const dbItem of dbItems) { - const collectionType = dbItem.path ? 'page' : 'data' + const itemHasPathField = 'path' in dbItem && dbItem.path const fsPathSegments = dbItem.fsPath.split('/') const directorySegments = fsPathSegments.slice(0, -1) let fileName = fsPathSegments[fsPathSegments.length - 1].replace(/\.[^/.]+$/, '') let routePathSegments: string[] | undefined - if (collectionType === 'page') { + if (itemHasPathField) { routePathSegments = (dbItem.path as string).split('/').slice(0, -1).filter(Boolean) } @@ -34,7 +35,7 @@ TreeItem[] { type: 'file', } - if (collectionType === 'page') { + if (itemHasPathField) { fileItem.routePath = dbItem.path as string } @@ -81,7 +82,7 @@ TreeItem[] { children: [], } - if (collectionType === 'page') { + if (itemHasPathField) { directory.routePath = dirRoutePathBuilder(i) } diff --git a/src/app/test/mocks/database.ts b/src/app/test/mocks/database.ts index f5e0dbce..dc9e9184 100644 --- a/src/app/test/mocks/database.ts +++ b/src/app/test/mocks/database.ts @@ -4,7 +4,7 @@ export const dbItemsList: (DatabaseItem & { fsPath: string })[] = [ { id: 'landing/index.md', title: '', - body: {}, + body: { type: 'minimark', value: [] }, description: '', extension: 'md', meta: {}, @@ -21,7 +21,7 @@ export const dbItemsList: (DatabaseItem & { fsPath: string })[] = [ { id: 'docs/1.getting-started/2.introduction.md', title: 'Introduction', - body: {}, + body: { type: 'minimark', value: [] }, description: 'Welcome to Docus theme documentation.', extension: 'md', layout: null, @@ -42,7 +42,7 @@ export const dbItemsList: (DatabaseItem & { fsPath: string })[] = [ { id: 'docs/1.getting-started/3.installation.md', title: 'Installation', - body: {}, + body: { type: 'minimark', value: [] }, description: 'Learn how to install Docus.', extension: 'md', layout: null, diff --git a/src/app/test/utils/context.test.ts b/src/app/test/utils/context.test.ts index 8323ba14..a65cebd4 100644 --- a/src/app/test/utils/context.test.ts +++ b/src/app/test/utils/context.test.ts @@ -16,7 +16,6 @@ describe('computeActionItems', () => { const rootItem: TreeItem = { type: 'root', name: 'content', - path: '/', } as TreeItem const result = computeActionItems(STUDIO_ITEM_ACTION_DEFINITIONS, rootItem) @@ -39,19 +38,18 @@ describe('computeActionItems', () => { it('should filter out actions for file items without draft status', () => { const fileItem: TreeItem = { type: 'file', - name: 'test.md', - path: '/test.md', + name: 'test.md' } as TreeItem const result = computeActionItems(STUDIO_ITEM_ACTION_DEFINITIONS, fileItem) expect(result.find(action => action.id === StudioItemActionId.CreateFolder)).toBeUndefined() - expect(result.find(action => action.id === StudioItemActionId.CreateFile)).toBeUndefined() + expect(result.find(action => action.id === StudioItemActionId.CreateDocument)).toBeUndefined() expect(result.find(action => action.id === StudioItemActionId.RevertItem)).toBeUndefined() const expectedActions = STUDIO_ITEM_ACTION_DEFINITIONS.filter(action => action.id !== StudioItemActionId.CreateFolder - && action.id !== StudioItemActionId.CreateFile + && action.id !== StudioItemActionId.CreateDocument && action.id !== StudioItemActionId.RevertItem, ) expect(result).toEqual(expectedActions) @@ -61,19 +59,18 @@ describe('computeActionItems', () => { const fileItem: TreeItem = { type: 'file', name: 'test.md', - path: '/test.md', status: DraftStatus.Opened, } as TreeItem const result = computeActionItems(STUDIO_ITEM_ACTION_DEFINITIONS, fileItem) expect(result.find(action => action.id === StudioItemActionId.CreateFolder)).toBeUndefined() - expect(result.find(action => action.id === StudioItemActionId.CreateFile)).toBeUndefined() + expect(result.find(action => action.id === StudioItemActionId.CreateDocument)).toBeUndefined() expect(result.find(action => action.id === StudioItemActionId.RevertItem)).toBeUndefined() const expectedActions = STUDIO_ITEM_ACTION_DEFINITIONS.filter(action => action.id !== StudioItemActionId.CreateFolder - && action.id !== StudioItemActionId.CreateFile + && action.id !== StudioItemActionId.CreateDocument && action.id !== StudioItemActionId.RevertItem, ) expect(result).toEqual(expectedActions) @@ -83,18 +80,17 @@ describe('computeActionItems', () => { const fileItem: TreeItem = { type: 'file', name: 'test.md', - path: '/test.md', status: DraftStatus.Updated, } as TreeItem const result = computeActionItems(STUDIO_ITEM_ACTION_DEFINITIONS, fileItem) expect(result.find(action => action.id === StudioItemActionId.CreateFolder)).toBeUndefined() - expect(result.find(action => action.id === StudioItemActionId.CreateFile)).toBeUndefined() + expect(result.find(action => action.id === StudioItemActionId.CreateDocument)).toBeUndefined() const expectedActions = STUDIO_ITEM_ACTION_DEFINITIONS.filter(action => action.id !== StudioItemActionId.CreateFolder - && action.id !== StudioItemActionId.CreateFile, + && action.id !== StudioItemActionId.CreateDocument, ) expect(result).toEqual(expectedActions) }) @@ -103,18 +99,17 @@ describe('computeActionItems', () => { const fileItem: TreeItem = { type: 'file', name: 'test.md', - path: '/test.md', status: DraftStatus.Created, } as TreeItem const result = computeActionItems(STUDIO_ITEM_ACTION_DEFINITIONS, fileItem) expect(result.find(action => action.id === StudioItemActionId.CreateFolder)).toBeUndefined() - expect(result.find(action => action.id === StudioItemActionId.CreateFile)).toBeUndefined() + expect(result.find(action => action.id === StudioItemActionId.CreateDocument)).toBeUndefined() const expectedActions = STUDIO_ITEM_ACTION_DEFINITIONS.filter(action => action.id !== StudioItemActionId.CreateFolder - && action.id !== StudioItemActionId.CreateFile, + && action.id !== StudioItemActionId.CreateDocument, ) expect(result).toEqual(expectedActions) }) @@ -123,21 +118,20 @@ describe('computeActionItems', () => { const fileItem: TreeItem = { type: 'file', name: 'test.md', - path: '/test.md', status: DraftStatus.Deleted, } as TreeItem const result = computeActionItems(STUDIO_ITEM_ACTION_DEFINITIONS, fileItem) expect(result.find(action => action.id === StudioItemActionId.CreateFolder)).toBeUndefined() - expect(result.find(action => action.id === StudioItemActionId.CreateFile)).toBeUndefined() + expect(result.find(action => action.id === StudioItemActionId.CreateDocument)).toBeUndefined() expect(result.find(action => action.id === StudioItemActionId.DuplicateItem)).toBeUndefined() expect(result.find(action => action.id === StudioItemActionId.RenameItem)).toBeUndefined() expect(result.find(action => action.id === StudioItemActionId.DeleteItem)).toBeUndefined() const expectedActions = STUDIO_ITEM_ACTION_DEFINITIONS.filter(action => action.id !== StudioItemActionId.CreateFolder - && action.id !== StudioItemActionId.CreateFile + && action.id !== StudioItemActionId.CreateDocument && action.id !== StudioItemActionId.DuplicateItem && action.id !== StudioItemActionId.RenameItem && action.id !== StudioItemActionId.DeleteItem, @@ -149,19 +143,18 @@ describe('computeActionItems', () => { const fileItem: TreeItem = { type: 'file', name: 'test.md', - path: '/test.md', status: DraftStatus.Renamed, } as TreeItem const result = computeActionItems(STUDIO_ITEM_ACTION_DEFINITIONS, fileItem) expect(result.find(action => action.id === StudioItemActionId.CreateFolder)).toBeUndefined() - expect(result.find(action => action.id === StudioItemActionId.CreateFile)).toBeUndefined() + expect(result.find(action => action.id === StudioItemActionId.CreateDocument)).toBeUndefined() expect(result.find(action => action.id === StudioItemActionId.RenameItem)).toBeUndefined() const expectedActions = STUDIO_ITEM_ACTION_DEFINITIONS.filter(action => action.id !== StudioItemActionId.CreateFolder - && action.id !== StudioItemActionId.CreateFile + && action.id !== StudioItemActionId.CreateDocument && action.id !== StudioItemActionId.RenameItem, ) expect(result).toEqual(expectedActions) @@ -175,7 +168,6 @@ describe('computeActionItems', () => { const directoryItem: TreeItem = { type: 'directory', name: 'folder', - path: '/folder', } as TreeItem const result = computeActionItems(STUDIO_ITEM_ACTION_DEFINITIONS, directoryItem) @@ -194,7 +186,6 @@ describe('computeActionItems', () => { const directoryItem: TreeItem = { type: 'directory', name: 'folder', - path: '/folder', status: DraftStatus.Opened, } as TreeItem @@ -214,7 +205,6 @@ describe('computeActionItems', () => { const directoryItem: TreeItem = { type: 'directory', name: 'folder', - path: '/folder', status: DraftStatus.Updated, } as TreeItem @@ -232,7 +222,6 @@ describe('computeActionItems', () => { const directoryItem: TreeItem = { type: 'directory', name: 'folder', - path: '/folder', status: DraftStatus.Created, } as TreeItem @@ -250,7 +239,6 @@ describe('computeActionItems', () => { const directoryItem: TreeItem = { type: 'directory', name: 'folder', - path: '/folder', status: DraftStatus.Deleted, } as TreeItem @@ -272,7 +260,6 @@ describe('computeActionItems', () => { const directoryItem: TreeItem = { type: 'directory', name: 'folder', - path: '/folder', status: DraftStatus.Renamed, } as TreeItem diff --git a/src/app/test/utils/draft.test.ts b/src/app/test/utils/draft.test.ts index 1cf0211b..1fcd420d 100644 --- a/src/app/test/utils/draft.test.ts +++ b/src/app/test/utils/draft.test.ts @@ -10,7 +10,10 @@ describe('getDraftStatus', () => { const draft: DatabaseItem = { id: 'landing/index.md', title: 'Home', - body: {}, + body: { + type: 'minimark', + value: [], + }, description: 'Home page', extension: 'md', stem: 'index', diff --git a/src/app/test/utils/tree.test.ts b/src/app/test/utils/tree.test.ts index 5d519f4b..7efb523c 100644 --- a/src/app/test/utils/tree.test.ts +++ b/src/app/test/utils/tree.test.ts @@ -3,7 +3,7 @@ import { buildTree, findParentFromId, findItemFromRoute, findItemFromId } from ' import { tree } from '../mocks/tree' import type { TreeItem } from '../../src/types/tree' import { dbItemsList } from '../mocks/database' -import type { DraftFileItem } from '../../src/types/draft' +import type { DraftItem } from '../../src/types/draft' import { DraftStatus } from '../../src/types/draft' import type { RouteLocationNormalized } from 'vue-router' @@ -48,7 +48,7 @@ describe('buildTree', () => { }) it('should build a tree from a list of database items and set file status for root file based on draft', () => { - const draftList: DraftFileItem[] = [{ + const draftList: DraftItem[] = [{ id: dbItemsList[0].id, fsPath: 'index.md', status: DraftStatus.Created, @@ -62,7 +62,7 @@ describe('buildTree', () => { }) it('should build a tree from a list of database items and set file status for nestedfile and parent directory based on draft', () => { - const draftList: DraftFileItem[] = [{ + const draftList: DraftItem[] = [{ id: dbItemsList[1].id, fsPath: '1.getting-started/2.introduction.md', status: DraftStatus.Updated, @@ -85,7 +85,7 @@ describe('buildTree', () => { }) it('should build a tree from a list of database items and set file status for nestedfile and parent directory based on draft (status is always updated in directory)', () => { - const draftList: DraftFileItem[] = [{ + const draftList: DraftItem[] = [{ id: dbItemsList[1].id, fsPath: '1.getting-started/2.introduction.md', status: DraftStatus.Created, diff --git a/src/module/src/module.ts b/src/module/src/module.ts index fb41c608..351459bf 100644 --- a/src/module/src/module.ts +++ b/src/module/src/module.ts @@ -1,7 +1,9 @@ -import { defineNuxtModule, createResolver, addPlugin, extendViteConfig, useLogger, addServerHandler } from '@nuxt/kit' +import { defineNuxtModule, createResolver, addPlugin, extendViteConfig, useLogger, addServerHandler, addTemplate } from '@nuxt/kit' import { createHash } from 'node:crypto' import { defu } from 'defu' import { resolve } from 'node:path' +import fsDriver from 'unstorage/drivers/fs' +import { createStorage } from 'unstorage' interface ModuleOptions { devStorage?: boolean @@ -99,6 +101,40 @@ export default defineNuxtModule({ }) } + const assetsStorage = createStorage({ + driver: fsDriver({ + base: resolve(nuxt.options.rootDir, 'public'), + }), + }) + assetsStorage.getKeys() + .then((keys) => { + addTemplate({ + filename: 'content-studio-public-assets.mjs', + getContents: () => { + return [ + 'import { createStorage } from \'unstorage\'', + 'const storage = createStorage({})', + '', + ...keys.map((key) => { + key = `public-assets:${key}` + const value = { + id: key, + extension: key.split('.').pop(), + stem: key.split('.').slice(0, -1).join('.'), + path: '/' + key.replace(/:/g, '/'), + } + return `storage.setItem('${key}', ${JSON.stringify(value)})` + }), + '', + 'export const publicAssetsStorage = storage', + ].join('\n') + }, + }) + }) + .catch((error) => { + console.error(error) + }) + addServerHandler({ route: '/__nuxt_content/studio/auth/github', handler: runtime('./server/routes/auth/github.get'), diff --git a/src/module/src/runtime/host.ts b/src/module/src/runtime/host.ts index cadaea79..bb89b511 100644 --- a/src/module/src/runtime/host.ts +++ b/src/module/src/runtime/host.ts @@ -4,12 +4,13 @@ import type { CollectionItemBase, DatabaseAdapter } from '@nuxt/content' import type { ContentDatabaseAdapter } from '../types/content' import { getCollectionByFilePath, generateIdFromFsPath, createCollectionDocument, generateRecordDeletion, generateRecordInsert, getCollectionInfo } from './utils/collections' import { kebabCase } from 'lodash' -import type { UseStudioHost, StudioHost, StudioUser, DatabaseItem } from 'nuxt-studio/app' +import type { UseStudioHost, StudioHost, StudioUser, DatabaseItem, MediaItem } from 'nuxt-studio/app' import type { RouteLocationNormalized, Router } from 'vue-router' import { generateDocumentFromContent } from './utils/content' // @ts-expect-error queryCollection is not defined in .nuxt/imports.d.ts import { queryCollection, queryCollectionItemSurroundings, queryCollectionNavigation, queryCollectionSearchSections } from '#imports' import { collections } from '#content/preview' +import { publicAssetsStorage } from '#build/content-studio-public-assets' function getSidebarWidth(): number { let sidebarWidth = 440 @@ -205,6 +206,40 @@ export function useStudioHost(user: StudioUser): StudioHost { }, }, + media: { + get: async (id: string): Promise => { + return await publicAssetsStorage.getItem(id) as MediaItem + }, + getFileSystemPath: (id: string) => { + return id.split(':').slice(1).join('/') + }, + list: async (): Promise => { + return await Promise.all(await publicAssetsStorage.getKeys().then(keys => keys.map(key => publicAssetsStorage.getItem(key)))) as MediaItem[] + }, + upsert: async (id: string, upsertedDocument: MediaItem) => { + await publicAssetsStorage.setItem(id, upsertedDocument) + }, + create: async (fsPath: string, routePath: string, content: string) => { + await publicAssetsStorage.setItem(fsPath, { + id: fsPath, + fsPath, + routePath, + content, + }) + return { + id: fsPath, + extension: fsPath.split('.').pop(), + stem: fsPath.split('.').slice(0, -1).join('.'), + fsPath, + routePath, + content, + } as MediaItem + }, + delete: async (id: string) => { + await publicAssetsStorage.removeItem(id) + }, + }, + app: { requestRerender: () => { useNuxtApp().hooks.callHookParallel('app:data:refresh') diff --git a/src/module/src/types/global.d.ts b/src/module/src/types/global.d.ts index b03e8ee5..82b881e1 100644 --- a/src/module/src/types/global.d.ts +++ b/src/module/src/types/global.d.ts @@ -5,3 +5,9 @@ declare module '#content/preview' { export const gitInfo: GitInfo export const appConfigSchema: Record } + +declare module '#build/content-studio-public-assets' { + import type { Storage } from 'unstorage' + + export const publicAssetsStorage: Storage +}