Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
"./app/utils": {
"types": "./dist/app/utils.d.ts",
"default": "./dist/app/utils.js"
},
"./app/service-worker": {
"types": "./dist/app/service-worker.d.ts",
"default": "./dist/app/service-worker.js"
}
},
"main": "./dist/module/module.mjs",
Expand Down
4 changes: 2 additions & 2 deletions src/app/src/components/panel/base/PanelBase.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,11 @@ function onLeave(el: Element, done: () => void) {
>
<PanelBaseHeader />

<div class="px-4 py-2 h-[var(--ui-sub-header-height)] border-b border-gray-200">
<div class="px-4 py-2 h-[var(--ui-sub-header-height)] border-b border-gray-200 sticky top-0 bg-white">
<slot name="header" />
</div>

<div class="h-[calc(100vh-var(--ui-header-height)-var(--ui-sub-header-height)-var(--ui-footer-height))] p-4">
<div class="h-[calc(100vh-var(--ui-header-height)-var(--ui-sub-header-height)-var(--ui-footer-height))] p-4 overflow-y-auto">
<slot />
</div>

Expand Down
17 changes: 16 additions & 1 deletion src/app/src/components/panel/base/PanelBaseBody.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ const fileTree = computed(() => (tree.value.current.value || []).filter(f => f.t

const isFileCreationInProgress = computed(() => context.actionInProgress.value === StudioItemActionId.CreateDocument)
const isFolderCreationInProgress = computed(() => context.actionInProgress.value === StudioItemActionId.CreateFolder)

async function onFileDrop(event: DragEvent) {
if (event.dataTransfer?.files) {
if (context.feature.value !== StudioFeature.Media) {
return
}

await context.itemActionHandler[StudioItemActionId.UploadMedia]({
directory: tree.value.currentItem.value.fsPath,
files: Array.from(event.dataTransfer.files),
})
}
}
</script>

<template>
Expand All @@ -23,7 +36,9 @@ const isFolderCreationInProgress = computed(() => context.actionInProgress.value
/>
<div
v-else
class="flex flex-col"
class="flex flex-col min-h-full"
@drop.prevent.stop="onFileDrop"
@dragover.prevent.stop
>
<PanelBaseBodyTree
v-if="folderTree?.length > 0 || isFolderCreationInProgress"
Expand Down
2 changes: 1 addition & 1 deletion src/app/src/components/panel/base/PanelBaseFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const userMenuItems = computed(() => [
</script>

<template>
<UFooter class="h-var(--ui-footer-height)">
<UFooter class="h-var(--ui-footer-height) sticky bottom-0 bg-white">
<template #left>
<UDropdownMenu
:portal="false"
Expand Down
4 changes: 2 additions & 2 deletions src/app/src/components/shared/item/ItemActionsDropdown.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computeActionItems } from '../../../utils/context'
import { computeActionItems, computeActionParams } from '../../../utils/context'
import { computed, type PropType } from 'vue'
import type { TreeItem } from '../../../types'
import { useStudio } from '../../../composables/useStudio'
Expand All @@ -17,7 +17,7 @@ const props = defineProps({
const actions = computed<DropdownMenuItem[]>(() => {
return computeActionItems(context.itemActions.value, props.item).map(action => ({
...action,
onSelect: action.handler,
onSelect: () => action.handler?.(computeActionParams(action.id, { item: props.item })),
}))
})
</script>
Expand Down
39 changes: 27 additions & 12 deletions src/app/src/components/shared/item/ItemCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ const isFolder = computed(() => props.item.type === 'directory')
const name = computed(() => titleCase(props.item.name))

const itemExtensionIcon = computed(() => {
if (props.item.preview) {
return ''
}
const ext = props.item.id.split('.').pop()?.toLowerCase() || ''
return {
md: 'i-ph-markdown-logo',
Expand All @@ -26,31 +29,43 @@ const itemExtensionIcon = computed(() => {
}[ext] || 'i-mdi-file'
})

const imageSrc = computed(() => {
if (props.item.preview) {
return props.item.preview
}
return 'https://placehold.co/1920x1080/f9fafc/f9fafc'
})

const statusRingColor = computed(() => props.item.status ? `ring-${COLOR_STATUS_MAP[props.item.status]}-200 hover:ring-${COLOR_STATUS_MAP[props.item.status]}-300 hover:dark:ring-${COLOR_STATUS_MAP[props.item.status]}-700` : 'ring-gray-200 hover:ring-gray-300 hover:dark:ring-gray-700')
</script>

<template>
<UPageCard
reverse
class="cursor-pointer hover:bg-white relative w-full min-w-0"
class="cursor-pointer hover:bg-white relative w-full min-w-0 overflow-hidden"
:class="statusRingColor"
>
<div
v-if="item.type === 'file'"
class="relative"
>
<Image
src="https://placehold.co/1920x1080/f9fafc/f9fafc"
width="426"
height="240"
alt="Card placeholder"
class="z-[-1] rounded-t-lg"
/>
<div class="absolute inset-0 flex items-center justify-center">
<UIcon
:name="itemExtensionIcon"
class="w-8 h-8 text-gray-400 dark:text-gray-500"
<div class="bg-[#f7f9fa] bg-[linear-gradient(45deg,#e6e9ea_25%,transparent_0),linear-gradient(-45deg,#e6e9ea_25%,transparent_0),linear-gradient(45deg,transparent_75%,#e6e9ea_0),linear-gradient(-45deg,transparent_75%,#e6e9ea_0)] bg-size-[24px_24px] bg-position-[0_0,0_12px,12px_-12px,-12px_0]">
<Image
:src="imageSrc"
width="426"
height="240"
alt="Card placeholder"
class="z-[-1] rounded-t-lg aspect-video object-cover"
/>
<div
v-if="itemExtensionIcon"
class="absolute inset-0 flex items-center justify-center"
>
<UIcon
:name="itemExtensionIcon"
class="w-8 h-8 text-gray-400 dark:text-gray-500"
/>
</div>
</div>
<ItemBadge
v-if="item.status"
Expand Down
16 changes: 14 additions & 2 deletions src/app/src/composables/useContext.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createSharedComposable } from '@vueuse/core'
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 { type UploadMediaParams, type CreateFileParams, type StudioHost, type StudioAction, type TreeItem, StudioItemActionId, type ActionHandlerParams, StudioFeature } from '../types'
import { oneStepActions, STUDIO_ITEM_ACTION_DEFINITIONS, twoStepActions } from '../utils/context'
import type { useDraftDocuments } from './useDraftDocuments'
import { useModal } from './useModal'
Expand Down Expand Up @@ -59,8 +59,20 @@ export const useContext = createSharedComposable((
const draftItem = await draftDocuments.create(document)
tree.selectItemById(draftItem.id)
},
[StudioItemActionId.UploadMedia]: async ({ directory, files }: UploadMediaParams) => {
for (const file of files) {
await draftMedias.upload(directory, file)
}
},
[StudioItemActionId.RevertItem]: async (id: string) => {
modal.openConfirmActionModal(id, StudioItemActionId.RevertItem, () => draftDocuments.revert(id))
modal.openConfirmActionModal(id, StudioItemActionId.RevertItem, async () => {
if (currentFeature.value === StudioFeature.Content) {
await draftDocuments.revert(id)
}
else {
await draftMedias.revert(id)
}
})
},
[StudioItemActionId.RenameItem]: async ({ path, file }: { path: string, file: TreeItem }) => {
alert(`rename file ${path} ${file.name}`)
Expand Down
3 changes: 2 additions & 1 deletion src/app/src/composables/useDraftDocuments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { useHooks } from './useHooks'

const storage = createStorage({
driver: indexedDbDriver({
storeName: 'nuxt-content-studio-documents',
dbName: 'nuxt-content-studio-document',
storeName: 'drafts',
}),
})

Expand Down
61 changes: 60 additions & 1 deletion src/app/src/composables/useDraftMedias.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ref } from 'vue'
import { createStorage } from 'unstorage'
import indexedDbDriver from 'unstorage/drivers/indexedb'
import { joinURL, withLeadingSlash } from 'ufo'
import type { DraftItem, StudioHost, GithubFile, MediaItem } from '../types'
import { DraftStatus } from '../types/draft'
import type { useGit } from './useGit'
Expand All @@ -10,7 +11,8 @@ import { useHooks } from './useHooks'

const storage = createStorage({
driver: indexedDbDriver({
storeName: 'nuxt-content-studio-medias',
dbName: 'nuxt-content-studio-media',
storeName: 'drafts',
}),
})

Expand Down Expand Up @@ -198,6 +200,62 @@ export const useDraftMedias = createSharedComposable((host: StudioHost, git: Ret
select(draftItem)
}

async function upload(directory: string, file: File) {
const draftItem = await fileToDraftItem(directory, file)
host.media.upsert(draftItem.id, draftItem.modified!)
await create(draftItem.modified!)
}

async function fileToDraftItem(directory: string, file: File): Promise<DraftItem<MediaItem>> {
const rawData = await fileToDataUrl(file)
const fsPath = directory && directory !== '/' ? joinURL(directory, file.name) : file.name

return {
id: `public-assets/${fsPath}`,
fsPath,
githubFile: undefined,
status: DraftStatus.Created,
modified: {
id: `public-assets/${fsPath}`,
fsPath,
extension: fsPath.split('.').pop()!,
stem: fsPath.split('.').join('.'),
path: withLeadingSlash(fsPath),
preview: await resizedataURL(rawData, 128, 128),
raw: rawData,
},
}
}

function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result as string)
reader.onerror = error => reject(error)
})
}

function resizedataURL(datas: string, wantedWidth: number, wantedHeight: number): Promise<string> {
return new Promise(function (resolve) {
const img = document.createElement('img')
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!

canvas.width = wantedWidth
canvas.height = wantedHeight

ctx.drawImage(img, 0, 0, wantedWidth, wantedHeight)

const dataURI = canvas.toDataURL()

resolve(dataURI)
}
img.src = datas
})
}

return {
get,
create,
Expand All @@ -210,5 +268,6 @@ export const useDraftMedias = createSharedComposable((host: StudioHost, git: Ret
current,
select,
selectById,
upload,
}
})
90 changes: 90 additions & 0 deletions src/app/src/service-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
export const serviceWorker = () => `
const EXTENSIONS_WITH_PREVIEW = new Set([
'jpg',
'jpeg',
'png',
'gif',
'webp',
'ico',
'avif',
])
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
const isSameDomain = url.origin === self.location.origin;

if (!isSameDomain) {
return event.respondWith(fetch(event.request));
}

if (url.pathname.startsWith('/_ipx/_/') || EXTENSIONS_WITH_PREVIEW.has(url.pathname.split('.').pop())) {
console.log('Fetching from IndexedDB:', url.pathname);
return event.respondWith(fetchFromIndexedDB(event, url));
}

event.respondWith(fetch(event.request))
})

function fetchFromIndexedDB(event, url) {
const dbKey = ['public-assets:', url.pathname.replace(/^\\/+(_ipx\\/_\\/)?/, '').replace('/', ':')].join('')
return getData(dbKey).then(data => {
if (!data) {
console.log('No data found in IndexedDB:', url.pathnam, dbKey);
return fetch(event.request);
}

const json = JSON.parse(data)
const parsed = parseDataUrl(json.modified.raw);
const bytes = base64ToUint8Array(parsed.base64);

return new Response(bytes, {
headers: { 'Content-Type': parsed.mime }
});
})
}

function parseDataUrl(dataUrl) {
// Example: data:image/png;base64,iVBORw0KG...
const match = dataUrl.match(/^data:(.+);base64,(.+)$/);
if (!match) return null;
return {
mime: match[1],
base64: match[2]
};
}

function base64ToUint8Array(base64) {
const binary = atob(base64);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}

// IndexedDB
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('nuxt-content-studio-media', 1);
request.onupgradeneeded = event => {
const db = event.target.result;
db.createObjectStore('drafts', { keyPath: 'id' });
};
request.onsuccess = event => resolve(event.target.result);
request.onerror = event => reject(event.target.error);
});
}

// Read data from the object store
function getData(key) {
return openDB().then(db => {
return new Promise((resolve, reject) => {
const tx = db.transaction('drafts', 'readonly');
const store = tx.objectStore('drafts');
const request = store.get(key);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
});
}
`
9 changes: 8 additions & 1 deletion src/app/src/types/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export enum StudioFeature {
export enum StudioItemActionId {
CreateFolder = 'create-folder',
CreateDocument = 'create-document',
UploadMedia = 'upload-media',
RevertItem = 'revert-item',
RenameItem = 'rename-item',
DeleteItem = 'delete-item',
Expand All @@ -19,7 +20,7 @@ export interface StudioAction {
label: string
icon: string
tooltip: string
handler?: (args: string & CreateFileParams & RenameFileParams) => void
handler?: (args: ActionHandlerParams[StudioItemActionId]) => void
}

export interface CreateFileParams {
Expand All @@ -33,9 +34,15 @@ export interface RenameFileParams {
file: TreeItem
}

export interface UploadMediaParams {
directory: string
files: File[]
}

export type ActionHandlerParams = {
[StudioItemActionId.CreateFolder]: string
[StudioItemActionId.CreateDocument]: CreateFileParams
[StudioItemActionId.UploadMedia]: UploadMediaParams
[StudioItemActionId.RevertItem]: string
[StudioItemActionId.RenameItem]: RenameFileParams
[StudioItemActionId.DeleteItem]: string
Expand Down
Loading