mirror of
https://github.com/directus/directus.git
synced 2026-01-27 01:17:57 -05:00
Re-style file detail preview + replace interaction (#12689)
* Return BigIntegers as Strings in GraphQL Fixes #12051 * Redesign fallback image style * Re-add replace button in new position
This commit is contained in:
@@ -29,6 +29,7 @@ import VForm from './v-form';
|
||||
import VHover from './v-hover/';
|
||||
import VHighlight from './v-highlight.vue';
|
||||
import VIcon from './v-icon/';
|
||||
import VIconFile from './v-icon-file.vue';
|
||||
import VInfo from './v-info/';
|
||||
import VInput from './v-input/';
|
||||
import VItemGroup, { VItem } from './v-item-group';
|
||||
@@ -77,6 +78,7 @@ export function registerComponents(app: App): void {
|
||||
app.component('VHover', VHover);
|
||||
app.component('VHighlight', VHighlight);
|
||||
app.component('VIcon', VIcon);
|
||||
app.component('VIconFile', VIconFile);
|
||||
app.component('VInfo', VInfo);
|
||||
app.component('VInput', VInput);
|
||||
app.component('VItemGroup', VItemGroup);
|
||||
|
||||
51
app/src/components/v-icon-file.vue
Normal file
51
app/src/components/v-icon-file.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="icon" :class="{ right: ext.length >= 4 }">
|
||||
<v-icon name="insert_drive_file" />
|
||||
<span class="label">{{ ext }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
interface Props {
|
||||
ext: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:global(body) {
|
||||
--v-icon-file-color: var(--primary);
|
||||
--v-icon-file-background-color: var(--background-normal);
|
||||
}
|
||||
|
||||
.icon {
|
||||
--v-icon-size: 64px;
|
||||
--v-icon-color: var(--v-icon-file-color);
|
||||
color: var(--v-icon-file-color);
|
||||
position: relative;
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
text-transform: uppercase;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: 55%;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
padding: 2px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.right {
|
||||
.label {
|
||||
background-color: var(--v-icon-file-background-color);
|
||||
left: calc(100% - 12px - 3ch);
|
||||
text-align: left;
|
||||
transform: none;
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="selection-fade"></div>
|
||||
<v-skeleton-loader v-if="loading" />
|
||||
<template v-else>
|
||||
<p v-if="type || imgError" class="type type-title">{{ type }}</p>
|
||||
<v-icon-file v-if="type || imgError" :ext="type" />
|
||||
<template v-else>
|
||||
<img
|
||||
v-if="imageSource"
|
||||
@@ -177,7 +177,7 @@ export default defineComponent({
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--background-normal-alt);
|
||||
background-color: var(--background-normal);
|
||||
border-color: var(--primary-50);
|
||||
border-style: solid;
|
||||
border-width: 0px;
|
||||
|
||||
@@ -16,36 +16,29 @@
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
file: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
preset: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'replaced'],
|
||||
setup(_props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
interface Props {
|
||||
modelValue?: boolean;
|
||||
file?: Record<string, any>;
|
||||
preset?: Record<string, any>;
|
||||
}
|
||||
|
||||
return { t, uploaded };
|
||||
function uploaded() {
|
||||
emit('update:modelValue', false);
|
||||
emit('replaced');
|
||||
}
|
||||
},
|
||||
withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
file: () => ({}),
|
||||
preset: () => ({}),
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'replaced']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
function uploaded() {
|
||||
emit('update:modelValue', false);
|
||||
emit('replaced');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -108,15 +108,20 @@
|
||||
</template>
|
||||
|
||||
<div class="file-item">
|
||||
<file-preview
|
||||
v-if="isBatch === false && item"
|
||||
:src="fileSrc"
|
||||
:mime="item.type"
|
||||
:width="item.width"
|
||||
:height="item.height"
|
||||
:title="item.title"
|
||||
@click="replaceFileDialogActive = true"
|
||||
/>
|
||||
<div class="preview">
|
||||
<file-preview
|
||||
v-if="isBatch === false && item"
|
||||
:src="fileSrc"
|
||||
:mime="item.type"
|
||||
:width="item.width"
|
||||
:height="item.height"
|
||||
:title="item.title"
|
||||
/>
|
||||
|
||||
<button v-if="isBatch === false && item" class="replace-toggle" @click="replaceFileDialogActive = true">
|
||||
{{ t('replace_file') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<image-editor
|
||||
v-if="item && item.type.startsWith('image')"
|
||||
@@ -155,7 +160,7 @@
|
||||
<file-info-sidebar-detail :file="item" />
|
||||
<revisions-drawer-detail
|
||||
v-if="isBatch === false && isNew === false && revisionsAllowed"
|
||||
ref="revisionsDrawerDetail"
|
||||
ref="revisionsDrawerDetailRef"
|
||||
collection="directus_files"
|
||||
:primary-key="primaryKey"
|
||||
/>
|
||||
@@ -170,292 +175,229 @@
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, computed, toRefs, ref, watch, ComponentPublicInstance } from 'vue';
|
||||
import FilesNavigation from '../components/navigation.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail';
|
||||
import CommentsSidebarDetail from '@/views/private/components/comments-sidebar-detail';
|
||||
import useItem from '@/composables/use-item';
|
||||
import SaveOptions from '@/views/private/components/save-options';
|
||||
import FilePreview from '@/views/private/components/file-preview';
|
||||
import ImageEditor from '@/views/private/components/image-editor';
|
||||
import { Field } from '@directus/shared/types';
|
||||
import FileInfoSidebarDetail from '../components/file-info-sidebar-detail.vue';
|
||||
import FolderPicker from '@/views/private/components/folder-picker/folder-picker.vue';
|
||||
<script lang="ts" setup>
|
||||
import api, { addTokenToURL } from '@/api';
|
||||
import { getRootPath } from '@/utils/get-root-path';
|
||||
import FilesNotFound from './not-found.vue';
|
||||
import useShortcut from '@/composables/use-shortcut';
|
||||
import ReplaceFile from '../components/replace-file.vue';
|
||||
import useEditsGuard from '@/composables/use-edits-guard';
|
||||
import useItem from '@/composables/use-item';
|
||||
import { usePermissions } from '@/composables/use-permissions';
|
||||
import useShortcut from '@/composables/use-shortcut';
|
||||
import { getRootPath } from '@/utils/get-root-path';
|
||||
import { notify } from '@/utils/notify';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import useEditsGuard from '@/composables/use-edits-guard';
|
||||
import CommentsSidebarDetail from '@/views/private/components/comments-sidebar-detail';
|
||||
import FilePreview from '@/views/private/components/file-preview';
|
||||
import FolderPicker from '@/views/private/components/folder-picker/folder-picker.vue';
|
||||
import ImageEditor from '@/views/private/components/image-editor';
|
||||
import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail';
|
||||
import SaveOptions from '@/views/private/components/save-options';
|
||||
import { Field } from '@directus/shared/types';
|
||||
import { ComponentPublicInstance, computed, ref, toRefs, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import FileInfoSidebarDetail from '../components/file-info-sidebar-detail.vue';
|
||||
import FilesNavigation from '../components/navigation.vue';
|
||||
import ReplaceFile from '../components/replace-file.vue';
|
||||
import FilesNotFound from './not-found.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FilesItem',
|
||||
components: {
|
||||
FilesNavigation,
|
||||
RevisionsDrawerDetail,
|
||||
CommentsSidebarDetail,
|
||||
FilePreview,
|
||||
ImageEditor,
|
||||
FileInfoSidebarDetail,
|
||||
FolderPicker,
|
||||
FilesNotFound,
|
||||
ReplaceFile,
|
||||
SaveOptions,
|
||||
},
|
||||
props: {
|
||||
primaryKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { t } = useI18n();
|
||||
interface Props {
|
||||
primaryKey: string;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const form = ref<HTMLElement>();
|
||||
const { primaryKey } = toRefs(props);
|
||||
const { breadcrumb } = useBreadcrumb();
|
||||
const replaceFileDialogActive = ref(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
const revisionsDrawerDetail = ref<ComponentPublicInstance | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
isNew,
|
||||
edits,
|
||||
hasEdits,
|
||||
item,
|
||||
saving,
|
||||
loading,
|
||||
error,
|
||||
save,
|
||||
remove,
|
||||
deleting,
|
||||
saveAsCopy,
|
||||
isBatch,
|
||||
refresh,
|
||||
validationErrors,
|
||||
} = useItem(ref('directus_files'), primaryKey);
|
||||
const form = ref<HTMLElement>();
|
||||
const { primaryKey } = toRefs(props);
|
||||
const { breadcrumb } = useBreadcrumb();
|
||||
const replaceFileDialogActive = ref(false);
|
||||
|
||||
const isSavable = computed(() => saveAllowed.value && hasEdits.value);
|
||||
const revisionsDrawerDetailRef = ref<ComponentPublicInstance | null>(null);
|
||||
|
||||
const { confirmLeave, leaveTo } = useEditsGuard(hasEdits);
|
||||
const {
|
||||
isNew,
|
||||
edits,
|
||||
hasEdits,
|
||||
item,
|
||||
saving,
|
||||
loading,
|
||||
save,
|
||||
remove,
|
||||
deleting,
|
||||
saveAsCopy,
|
||||
isBatch,
|
||||
refresh,
|
||||
validationErrors,
|
||||
} = useItem(ref('directus_files'), primaryKey);
|
||||
|
||||
const confirmDelete = ref(false);
|
||||
const editActive = ref(false);
|
||||
const fileSrc = computed(() => {
|
||||
if (item.value && item.value.modified_on) {
|
||||
return addTokenToURL(
|
||||
getRootPath() + `assets/${props.primaryKey}?cache-buster=${item.value.modified_on}&key=system-large-contain`
|
||||
);
|
||||
}
|
||||
const isSavable = computed(() => saveAllowed.value && hasEdits.value);
|
||||
|
||||
return addTokenToURL(getRootPath() + `assets/${props.primaryKey}?key=system-large-contain`);
|
||||
});
|
||||
const { confirmLeave, leaveTo } = useEditsGuard(hasEdits);
|
||||
|
||||
// These are the fields that will be prevented from showing up in the form because they'll be shown in the sidebar
|
||||
const fieldsDenyList: string[] = [
|
||||
'type',
|
||||
'width',
|
||||
'height',
|
||||
'filesize',
|
||||
'checksum',
|
||||
'uploaded_by',
|
||||
'uploaded_on',
|
||||
'modified_by',
|
||||
'modified_on',
|
||||
'duration',
|
||||
'folder',
|
||||
'charset',
|
||||
'embed',
|
||||
];
|
||||
|
||||
const to = computed(() => {
|
||||
if (item.value && item.value?.folder) return `/files/folders/${item.value.folder}`;
|
||||
else return '/files';
|
||||
});
|
||||
|
||||
const { moveToDialogActive, moveToFolder, moving, selectedFolder } = useMovetoFolder();
|
||||
|
||||
useShortcut('meta+s', saveAndStay, form);
|
||||
|
||||
const { createAllowed, deleteAllowed, saveAllowed, updateAllowed, fields, revisionsAllowed } = usePermissions(
|
||||
ref('directus_files'),
|
||||
item,
|
||||
isNew
|
||||
const confirmDelete = ref(false);
|
||||
const editActive = ref(false);
|
||||
const fileSrc = computed(() => {
|
||||
if (item.value && item.value.modified_on) {
|
||||
return addTokenToURL(
|
||||
getRootPath() + `assets/${props.primaryKey}?cache-buster=${item.value.modified_on}&key=system-large-contain`
|
||||
);
|
||||
}
|
||||
|
||||
const fieldsFiltered = computed(() => {
|
||||
return fields.value.filter((field: Field) => fieldsDenyList.includes(field.field) === false);
|
||||
});
|
||||
|
||||
return {
|
||||
t,
|
||||
router,
|
||||
item,
|
||||
loading,
|
||||
error,
|
||||
isNew,
|
||||
breadcrumb,
|
||||
edits,
|
||||
hasEdits,
|
||||
saving,
|
||||
saveAndQuit,
|
||||
deleteAndQuit,
|
||||
confirmDelete,
|
||||
deleting,
|
||||
saveAndStay,
|
||||
saveAsCopyAndNavigate,
|
||||
discardAndStay,
|
||||
isBatch,
|
||||
editActive,
|
||||
revisionsDrawerDetail,
|
||||
confirmLeave,
|
||||
leaveTo,
|
||||
discardAndLeave,
|
||||
downloadFile,
|
||||
moveToDialogActive,
|
||||
moveToFolder,
|
||||
moving,
|
||||
selectedFolder,
|
||||
fileSrc,
|
||||
form,
|
||||
to,
|
||||
replaceFileDialogActive,
|
||||
refresh,
|
||||
createAllowed,
|
||||
deleteAllowed,
|
||||
saveAllowed,
|
||||
updateAllowed,
|
||||
fields,
|
||||
fieldsFiltered,
|
||||
revisionsAllowed,
|
||||
validationErrors,
|
||||
isSavable,
|
||||
};
|
||||
|
||||
function useBreadcrumb() {
|
||||
const breadcrumb = computed(() => {
|
||||
if (!item?.value?.folder) {
|
||||
return [
|
||||
{
|
||||
name: t('file_library'),
|
||||
to: '/files',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: t('file_library'),
|
||||
to: { path: `/files/folders/${item.value.folder}` },
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return { breadcrumb };
|
||||
}
|
||||
|
||||
async function saveAndQuit() {
|
||||
try {
|
||||
await save();
|
||||
router.push(to.value);
|
||||
} catch {
|
||||
// `save` will show unexpected error dialog
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAndStay() {
|
||||
try {
|
||||
await save();
|
||||
revisionsDrawerDetail.value?.refresh?.();
|
||||
} catch {
|
||||
// `save` will show unexpected error dialog
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAsCopyAndNavigate() {
|
||||
const newPrimaryKey = await saveAsCopy();
|
||||
if (newPrimaryKey) router.push(`/files/${newPrimaryKey}`);
|
||||
}
|
||||
|
||||
async function deleteAndQuit() {
|
||||
try {
|
||||
await remove();
|
||||
router.replace(to.value);
|
||||
} catch {
|
||||
// `remove` will show the unexpected error dialog
|
||||
confirmDelete.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function discardAndLeave() {
|
||||
if (!leaveTo.value) return;
|
||||
edits.value = {};
|
||||
confirmLeave.value = false;
|
||||
router.push(leaveTo.value);
|
||||
}
|
||||
|
||||
function discardAndStay() {
|
||||
edits.value = {};
|
||||
confirmLeave.value = false;
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
const filePath = addTokenToURL(getRootPath() + `assets/${props.primaryKey}?download`);
|
||||
window.open(filePath, '_blank');
|
||||
}
|
||||
|
||||
function useMovetoFolder() {
|
||||
const moveToDialogActive = ref(false);
|
||||
const moving = ref(false);
|
||||
const selectedFolder = ref<number | null>();
|
||||
|
||||
watch(item, () => {
|
||||
selectedFolder.value = item.value?.folder || null;
|
||||
});
|
||||
|
||||
return { moveToDialogActive, moving, moveToFolder, selectedFolder };
|
||||
|
||||
async function moveToFolder() {
|
||||
moving.value = true;
|
||||
|
||||
try {
|
||||
const response = await api.patch(
|
||||
`/files/${props.primaryKey}`,
|
||||
{
|
||||
folder: selectedFolder.value,
|
||||
},
|
||||
{
|
||||
params: {
|
||||
fields: 'folder.name',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await refresh();
|
||||
const folder = response.data.data.folder?.name || t('file_library');
|
||||
|
||||
notify({
|
||||
title: t('file_moved', { folder }),
|
||||
icon: 'folder_move',
|
||||
});
|
||||
} catch (err: any) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
moveToDialogActive.value = false;
|
||||
moving.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
return addTokenToURL(getRootPath() + `assets/${props.primaryKey}?key=system-large-contain`);
|
||||
});
|
||||
|
||||
// These are the fields that will be prevented from showing up in the form because they'll be shown in the sidebar
|
||||
const fieldsDenyList: string[] = [
|
||||
'type',
|
||||
'width',
|
||||
'height',
|
||||
'filesize',
|
||||
'checksum',
|
||||
'uploaded_by',
|
||||
'uploaded_on',
|
||||
'modified_by',
|
||||
'modified_on',
|
||||
'duration',
|
||||
'folder',
|
||||
'charset',
|
||||
'embed',
|
||||
];
|
||||
|
||||
const to = computed(() => {
|
||||
if (item.value && item.value?.folder) return `/files/folders/${item.value.folder}`;
|
||||
else return '/files';
|
||||
});
|
||||
|
||||
const { moveToDialogActive, moveToFolder, moving, selectedFolder } = useMovetoFolder();
|
||||
|
||||
useShortcut('meta+s', saveAndStay, form);
|
||||
|
||||
const { createAllowed, deleteAllowed, saveAllowed, updateAllowed, fields, revisionsAllowed } = usePermissions(
|
||||
ref('directus_files'),
|
||||
item,
|
||||
isNew
|
||||
);
|
||||
|
||||
const fieldsFiltered = computed(() => {
|
||||
return fields.value.filter((field: Field) => fieldsDenyList.includes(field.field) === false);
|
||||
});
|
||||
|
||||
function useBreadcrumb() {
|
||||
const breadcrumb = computed(() => {
|
||||
if (!item?.value?.folder) {
|
||||
return [
|
||||
{
|
||||
name: t('file_library'),
|
||||
to: '/files',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: t('file_library'),
|
||||
to: { path: `/files/folders/${item.value.folder}` },
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return { breadcrumb };
|
||||
}
|
||||
|
||||
async function saveAndQuit() {
|
||||
try {
|
||||
await save();
|
||||
router.push(to.value);
|
||||
} catch {
|
||||
// `save` will show unexpected error dialog
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAndStay() {
|
||||
try {
|
||||
await save();
|
||||
revisionsDrawerDetailRef.value?.refresh?.();
|
||||
} catch {
|
||||
// `save` will show unexpected error dialog
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAsCopyAndNavigate() {
|
||||
const newPrimaryKey = await saveAsCopy();
|
||||
if (newPrimaryKey) router.push(`/files/${newPrimaryKey}`);
|
||||
}
|
||||
|
||||
async function deleteAndQuit() {
|
||||
try {
|
||||
await remove();
|
||||
router.replace(to.value);
|
||||
} catch {
|
||||
// `remove` will show the unexpected error dialog
|
||||
confirmDelete.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function discardAndLeave() {
|
||||
if (!leaveTo.value) return;
|
||||
edits.value = {};
|
||||
confirmLeave.value = false;
|
||||
router.push(leaveTo.value);
|
||||
}
|
||||
|
||||
function discardAndStay() {
|
||||
edits.value = {};
|
||||
confirmLeave.value = false;
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
const filePath = addTokenToURL(getRootPath() + `assets/${props.primaryKey}?download`);
|
||||
window.open(filePath, '_blank');
|
||||
}
|
||||
|
||||
function useMovetoFolder() {
|
||||
const moveToDialogActive = ref(false);
|
||||
const moving = ref(false);
|
||||
const selectedFolder = ref<number | null>();
|
||||
|
||||
watch(item, () => {
|
||||
selectedFolder.value = item.value?.folder || null;
|
||||
});
|
||||
|
||||
return { moveToDialogActive, moving, moveToFolder, selectedFolder };
|
||||
|
||||
async function moveToFolder() {
|
||||
moving.value = true;
|
||||
|
||||
try {
|
||||
const response = await api.patch(
|
||||
`/files/${props.primaryKey}`,
|
||||
{
|
||||
folder: selectedFolder.value,
|
||||
},
|
||||
{
|
||||
params: {
|
||||
fields: 'folder.name',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await refresh();
|
||||
const folder = response.data.data.folder?.name || t('file_library');
|
||||
|
||||
notify({
|
||||
title: t('file_moved', { folder }),
|
||||
icon: 'folder_move',
|
||||
});
|
||||
} catch (err: any) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
moveToDialogActive.value = false;
|
||||
moving.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -472,4 +414,15 @@ export default defineComponent({
|
||||
padding: var(--content-padding);
|
||||
padding-bottom: var(--content-padding-bottom);
|
||||
}
|
||||
|
||||
.preview {
|
||||
margin-bottom: var(--form-vertical-gap);
|
||||
}
|
||||
|
||||
.replace-toggle {
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
<div v-if="type && !imgError" class="file-preview" :class="{ modal: inModal, small: isSmall, svg: isSVG }">
|
||||
<div v-if="type === 'image'" class="image" @click="$emit('click')">
|
||||
<img :src="src" :width="width" :height="height" :alt="title" @error="imgError = true" />
|
||||
<v-icon v-if="inModal === false" name="upload" />
|
||||
</div>
|
||||
|
||||
<video v-else-if="type === 'video'" controls :src="src" />
|
||||
|
||||
<audio v-else-if="type === 'audio'" controls :src="src" />
|
||||
|
||||
<div v-else class="fallback">
|
||||
<v-icon-file :ext="type" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { readableMimeType } from '@/utils/readable-mime-type';
|
||||
|
||||
interface Props {
|
||||
mime: string;
|
||||
@@ -29,8 +33,8 @@ const props = withDefaults(defineProps<Props>(), { width: undefined, height: und
|
||||
|
||||
const imgError = ref(false);
|
||||
|
||||
const type = computed<'image' | 'video' | 'audio' | null>(() => {
|
||||
if (props.mime === null) return null;
|
||||
const type = computed<'image' | 'video' | 'audio' | string>(() => {
|
||||
if (props.mime === null) return 'unknown';
|
||||
|
||||
if (props.mime.startsWith('image')) {
|
||||
return 'image';
|
||||
@@ -44,7 +48,7 @@ const type = computed<'image' | 'video' | 'audio' | null>(() => {
|
||||
return 'audio';
|
||||
}
|
||||
|
||||
return null;
|
||||
return readableMimeType(props.mime, true) ?? 'unknown';
|
||||
});
|
||||
|
||||
const isSVG = computed(() => props.mime.includes('svg'));
|
||||
@@ -57,7 +61,6 @@ const isSmall = computed(() => props.height < 528);
|
||||
.file-preview {
|
||||
position: relative;
|
||||
max-width: calc((var(--form-column-max-width) * 2) + var(--form-horizontal-gap));
|
||||
margin-bottom: var(--form-vertical-gap);
|
||||
|
||||
img,
|
||||
video,
|
||||
@@ -71,8 +74,6 @@ const isSmall = computed(() => props.height < 528);
|
||||
}
|
||||
|
||||
.image {
|
||||
cursor: pointer;
|
||||
|
||||
background-color: var(--background-normal);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
@@ -81,23 +82,15 @@ const isSmall = computed(() => props.height < 528);
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.v-icon {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 2;
|
||||
color: white;
|
||||
text-shadow: 0px 0px 8px rgb(0 0 0 / 0.75);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--transition);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.v-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.fallback {
|
||||
background-color: var(--background-normal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--input-height-tall);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
&.svg,
|
||||
|
||||
Reference in New Issue
Block a user