feat: Add new composer

This commit is contained in:
Jesse Wierzbinski 2024-11-30 19:15:23 +01:00
parent 49d356e2ab
commit 4dfbd845d3
No known key found for this signature in database
40 changed files with 584 additions and 222 deletions

View file

@ -0,0 +1,53 @@
<template>
<Popover.Root :positioning="{
strategy: 'fixed',
}" @update:open="o => !o" :close-on-interact-outside="false">
<Popover.Trigger aria-hidden="true"
class="absolute top-1 left-1 p-1 bg-dark-800 ring-1 ring-white/5 text-white text-xs rounded size-6">
<iconify-icon icon="tabler:alt" width="none" class="size-4" />
</Popover.Trigger>
<Popover.Positioner class="!z-[100]">
<Popover.Content
class="p-1 bg-dark-400 rounded text-sm ring-1 ring-white/10 shadow-lg text-gray-300 w-72 space-y-2">
<div class="flex items-center justify-between px-1 pt-1 gap-x-1">
<Popover.CloseTrigger :as-child="true">
<Button theme="outline" aria-label="Close" class="text-xs !p-1">
<iconify-icon icon="tabler:x" width="1rem" height="1rem" />
</Button>
</Popover.CloseTrigger>
<h3 class="text-xs font-semibold">Alt Text</h3>
<a :href="`https://www.w3.org/WAI/tutorials/images/decision-tree/`" target="_blank"
class="text-xs text-gray-300 ml-auto mr-1" title="Learn more about alt text">
<iconify-icon icon="tabler:info-circle" width="1rem" height="1rem" />
</a>
</div>
<PreviewContent :file="fileData.file" class="rounded" />
<textarea :disabled="fileData.uploading" @keydown.enter.stop v-model="fileData.alt_text"
placeholder="Describe this image for screen readers"
rows="5"
class="w-full p-2 text-sm prose prose-invert bg-dark-900 rounded focus:!ring-0 !ring-none !border-none !outline-none placeholder:text-zinc-500 appearance-none focus:!border-none focus:!outline-none" />
<Popover.CloseTrigger :as-child="true">
<Button theme="secondary" @click="$emit('update-alt-text', fileData.alt_text)" class="w-full"
:loading="fileData.uploading">
<span>Edit</span>
</Button>
</Popover.CloseTrigger>
</Popover.Content>
</Popover.Positioner>
</Popover.Root>
</template>
<script lang="ts" setup>
import { Popover } from "@ark-ui/vue";
import Button from "~/packages/ui/components/buttons/button.vue";
import PreviewContent from "./preview-content.vue";
import type { FileData } from "./uploader.vue";
const props = defineProps<{
fileData: FileData;
}>();
defineEmits<{
"update-alt-text": [text?: string];
}>();
</script>

View file

@ -0,0 +1,30 @@
<template>
<div role="button" tabindex="0" :class="[
'size-28 bg-dark-800 rounded flex items-center relative justify-center ring-1 ring-white/20 overflow-hidden',
]" @keydown.enter="$emit('remove', fileData.id)">
<PreviewContent :file="fileData.file" />
<FileShadowOverlay />
<FileSize :size="fileData.file.size" :uploading="fileData.uploading" />
<RemoveButton @remove="$emit('remove', fileData.id)" />
<AltTextEditor v-if="fileData.api_id" :file-data="fileData"
@update-alt-text="(text) => $emit('update-alt-text', fileData.id, text)" />
</div>
</template>
<script lang="ts" setup>
import AltTextEditor from "./alt-text-editor.vue";
import FileShadowOverlay from "./file-shadow-overlay.vue";
import FileSize from "./file-size.vue";
import PreviewContent from "./preview-content.vue";
import RemoveButton from "./remove-button.vue";
import type { FileData } from "./uploader.vue";
defineProps<{
fileData: FileData;
}>();
defineEmits<{
remove: [id: string];
"update-alt-text": [id: string, text?: string];
}>();
</script>

View file

@ -0,0 +1,3 @@
<template>
<div class="absolute inset-0 bg-black/70"></div>
</template>

View file

@ -0,0 +1,26 @@
<template>
<div class="absolute bottom-1 right-1 p-1 bg-dark-800 text-white text-xs rounded cursor-default flex flex-row items-center gap-x-1"
aria-label="File size">
{{ formatBytes(size) }}
<iconify-icon v-if="uploading" icon="tabler:loader-2" width="none"
class="size-4 animate-spin text-primary2-500" />
</div>
</template>
<script lang="ts" setup>
const props = defineProps<{
size: number;
uploading: boolean;
}>();
const formatBytes = (bytes: number) => {
if (bytes === 0) {
return "0 Bytes";
}
const k = 1000;
const dm = 2;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
};
</script>

View file

@ -0,0 +1,32 @@
<template>
<template v-if="file.type.startsWith('image/')">
<img :src="createObjectURL(file)" class="w-full h-full object-cover cursor-default" alt="Preview of file" />
</template>
<template v-else-if="file.type.startsWith('video/')">
<video :src="createObjectURL(file)" class="w-full h-full object-cover cursor-default" />
</template>
<template v-else>
<iconify-icon :icon="getIcon(file.type)" width="none" class="size-6" />
</template>
</template>
<script lang="ts" setup>
const props = defineProps<{
file: File;
}>();
const createObjectURL = URL.createObjectURL;
const getIcon = (mimeType: string) => {
if (mimeType.startsWith("image/")) {
return "tabler:photo";
}
if (mimeType.startsWith("video/")) {
return "tabler:video";
}
if (mimeType.startsWith("audio/")) {
return "tabler:music";
}
return "tabler:file";
};
</script>

View file

@ -0,0 +1,12 @@
<template>
<button class="absolute top-1 right-1 p-1 bg-dark-800 text-white text-xs rounded size-6" role="button" tabindex="0"
@pointerup="$emit('remove')" @keydown.enter="$emit('remove')">
<iconify-icon icon="tabler:x" width="none" class="size-4" />
</button>
</template>
<script lang="ts" setup>
defineEmits<{
remove: [];
}>();
</script>

View file

@ -0,0 +1,119 @@
<template>
<div>
<input type="file" ref="fileInput" @change="handleFileInput" style="display: none" multiple />
<div class="flex flex-row gap-2 overflow-x-auto *:shrink-0 p-1 mb-4" v-if="files.length > 0">
<FilePreview v-for="data in files" :key="data.id" :file-data="data" @remove="(id: string) => emit('removeFile', id)"
@update-alt-text="updateAltText" />
</div>
</div>
</template>
<script lang="ts" setup>
import { nanoid } from "nanoid";
import FilePreview from "./file-preview.vue";
const files = defineModel<FileData[]>("files", {
required: true,
});
export interface FileData {
id: string;
file: File;
uploading: boolean;
progress: number;
api_id?: string;
alt_text?: string;
}
const fileInput = ref<HTMLInputElement | null>(null);
const openFilePicker = () => {
fileInput.value?.click();
};
defineExpose({
openFilePicker,
});
const emit = defineEmits<{
changeFile: [changedFile: FileData];
addFile: [newFile: FileData];
removeFile: [id: string];
}>();
const handleFileInput = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files) {
files.value.push(
...Array.from(target.files).map((file) => ({
id: nanoid(),
file,
progress: 0,
uploading: true,
})),
);
}
};
// Upload new files (not existing, currently being uploaded files)
watch(
files,
(newFiles) => {
for (const data of newFiles) {
if (data.progress === 0) {
uploadFile(data.file);
}
}
},
{
deep: true,
},
);
const updateAltText = (id: string, altText?: string) => {
const foundFile = files.value.find((data) => data.id === id);
if (!foundFile) {
throw new Error("File with ID doesn't exist");
}
emit("changeFile", {
...foundFile,
uploading: true,
});
client.value
?.updateMedia(foundFile.api_id as string, { description: altText })
.then(() => {
emit("changeFile", {
...foundFile,
uploading: false,
});
});
};
const uploadFile = async (file: File) => {
const foundFile = files.value.find((data) => data.file === file);
if (!foundFile) {
throw new Error("File doesn't exist");
}
emit("changeFile", {
...foundFile,
uploading: true,
progress: 0.1,
});
client.value.uploadMedia(file).then((response) => {
const attachment = response.data;
emit("changeFile", {
...foundFile,
uploading: false,
progress: 1.0,
api_id: attachment.id,
});
});
};
</script>