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:
Rijk van Zanten
2022-04-11 18:24:12 -04:00
committed by GitHub
parent f92fb0762f
commit f89aa95140
6 changed files with 322 additions and 330 deletions

View File

@@ -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);

View 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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,