Various style tweaks (#555)

* icon width

* updated pagination style

* file preview zoom

WIP — shouldn’t show up on MODAL preview

* overlay/modal close button styling

* duplicate key

* bookmark styling

* card fade

also adds an rgb value for the page background variable

* style per page dropdown

* cards per page dropdown color

* inset non-dense notifications within sidebar

* reduce border radius for xs avatars

* hide non-expanded prepend/append

* reduce sidebar padding

this gives content a bit more room

* WIP: split and update comments and revisions

work in progress

* fix collections module name

* fix file library title

* consistent border on disabled

* fix title/breadcrumb positioning

* breadcrumb fixes

* add “open” button to image interface

WIP — this needs the actual logic, and we might want to remove a button

* hide presets delete until selection

* image shadow and subtext color

* Remove breadcrumb calculation

* increase contrast for image subtitle

* fix textarea hover style

* Update src/modules/collections/index.ts

* Fix typing of translateresult to format

* Add undefined check to collection name

* Put v-if on dialog instead of button

* Remove breadcrumb logic

* Remove breadcrumb calculation

* Rename shadow to collapsed in header bar

* fix rating star display going over table header

* show collection breadcrumb for bookmarks

WIP — needs the formatted collection title

* shorter error to avoid wrapping

* remove periods

* Fix standard font-size of divider label, use large in interface

* Add extra date format strings

* Structure comments, support date headers

* Add ability to delete comments

* Add edited on status badge

Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
Ben Haynes
2020-05-12 20:10:48 -04:00
committed by GitHub
parent ef1912c109
commit 7aa4911caa
50 changed files with 1465 additions and 439 deletions

View File

@@ -6,8 +6,8 @@
</div>
</transition>
<router-view v-if="!hydrating" />
<portal-target name="dialog-outlet" multiple />
<portal-target name="popper-outlet" multiple />
<portal-target name="outlet" multiple />
</div>
</template>

View File

@@ -55,6 +55,8 @@ body {
&.x-small {
--v-avatar-size: 24px;
border-radius: 2px;
}
&.small {

View File

@@ -37,7 +37,7 @@ export const basic = () =>
<v-button @click="active = false">Yes</v-button>
</v-sheet>
</v-dialog>
<portal-target name="dialog-outlet" />
<portal-target name="outlet" />
</div>
`,
});
@@ -66,7 +66,7 @@ export const activatorSlot = () =>
<v-button @click="active = false">Yes</v-button>
</v-sheet>
</v-dialog>
<portal-target name="dialog-outlet" />
<portal-target name="outlet" />
</div>
`,
});

View File

@@ -2,7 +2,7 @@
<div class="v-dialog">
<slot name="activator" v-bind="{ on: () => $emit('toggle', true) }" />
<portal to="dialog-outlet">
<portal to="outlet">
<transition name="dialog">
<div v-if="active" class="container" :class="[className]">
<v-overlay active absolute @click="emitToggle" />

View File

@@ -1,9 +1,9 @@
<template>
<div class="v-divider" :class="{ vertical, inlineTitle }">
<div class="v-divider" :class="{ vertical, inlineTitle, large }">
<hr role="separator" :aria-orientation="vertical ? 'vertical' : 'horizontal'" />
<span v-if="$slots.icon || $slots.default" class="wrapper">
<slot name="icon" class="icon" />
<span v-if="!vertical && $slots.default" class="title"><slot /></span>
<span v-if="!vertical && $slots.default" class="type-text"><slot /></span>
</span>
</div>
</template>
@@ -21,6 +21,10 @@ export default defineComponent({
type: Boolean,
default: true,
},
large: {
type: Boolean,
default: false,
},
},
});
</script>
@@ -53,8 +57,14 @@ body {
span.wrapper {
margin-right: 16px;
}
.type-text {
color: var(--v-divider-label-color);
font-weight: normal;
}
&.large .type-text {
font-weight: 400;
font-size: 24px;
}

View File

@@ -138,7 +138,7 @@ export const withMenu = () =>
</v-list-item>
</v-list>
</v-menu>
<portal-target name="popper-outlet" />
<portal-target name="outlet" />
</div>
`,
});

View File

@@ -45,7 +45,7 @@ export const basic = () =>
</v-list-item>
</v-list>
</v-menu>
<portal-target name="popper-outlet" />
<portal-target name="outlet" />
</div>
`,
});
@@ -72,7 +72,7 @@ export const withVModel = () =>
</v-list-item>
</v-list>
</v-menu>
<portal-target name="popper-outlet" />
<portal-target name="outlet" />
</div>
`,
});
@@ -124,7 +124,7 @@ export const positioning = () =>
</v-list-item>
</v-list>
</v-menu>
<portal-target name="popper-outlet" />
<portal-target name="outlet" />
</div>
`,
});
@@ -186,7 +186,7 @@ export const withEdgeOffset = () =>
</v-list>
</v-menu>
</div>
<portal-target name="popper-outlet" />
<portal-target name="outlet" />
</div>
`,
});
@@ -225,7 +225,7 @@ export const attached = () =>
</v-list-item>
</v-list>
</v-menu>
<portal-target name="popper-outlet" />
<portal-target name="outlet" />
</div>
`,
});

View File

@@ -12,7 +12,7 @@
/>
</div>
<portal to="popper-outlet">
<portal to="outlet">
<transition name="bounce">
<div
class="v-menu-popper"

View File

@@ -33,7 +33,7 @@ export const basic = () =>
<v-button @click="close">Close modal</v-button>
</template>
</v-modal>
<portal-target name="dialog-outlet" />
<portal-target name="outlet" />
</div>
`,
});
@@ -78,7 +78,7 @@ export const withNav = () =>
<v-button @click="close">Close modal</v-button>
</template>
</v-modal>
<portal-target name="dialog-outlet" />
<portal-target name="outlet" />
</div>
`,
});

View File

@@ -137,9 +137,9 @@ body {
}
.v-button {
--v-button-background-color-hover: var(--background-subdued);
--v-button-background-color-hover: var(--background-normal);
--v-button-background-color: var(--background-subdued);
--v-button-color: var(--foreground-subdued);
--v-button-color: var(--foreground-normal);
margin: 0 2px;
vertical-align: middle;
@@ -159,10 +159,10 @@ body {
}
&.active {
--v-button-background-color-hover: var(--primary-25);
--v-button-color-hover: var(--primary);
--v-button-background-color: var(--primary-25);
--v-button-color: var(--primary);
--v-button-background-color-hover: var(--foreground-normal);
--v-button-color-hover: var(--foreground-inverted);
--v-button-background-color: var(--foreground-normal);
--v-button-color: var(--foreground-inverted);
}
}
}

View File

@@ -51,7 +51,7 @@ export const basic = () =>
:inline="inline"
/>
<raw-value>{{ value }}</raw-value>
<portal-target name="popper-outlet" />
<portal-target name="outlet" />
</div>
`,
});
@@ -89,7 +89,7 @@ export const multiple = () =>
multiple
/>
<raw-value>{{ value }}</raw-value>
<portal-target name="popper-outlet" />
<portal-target name="outlet" />
</div>
`,
});

View File

@@ -302,7 +302,7 @@ export default defineComponent({
.fixed {
position: sticky;
top: var(--v-table-sticky-offset-top);
z-index: 2;
z-index: 3;
}
.manual {

View File

@@ -112,6 +112,7 @@ body {
.prepend {
opacity: 0;
transition: opacity var(--medium) var(--transition);
pointer-events: none;
}
&:focus,
@@ -122,12 +123,13 @@ body {
.append,
.prepend {
opacity: 1;
pointer-events: auto;
}
}
}
&:hover {
border-color: var(--border-normal);
border-color: var(--border-normal-alt);
}
&:focus,
@@ -142,7 +144,6 @@ body {
&.disabled {
color: var(--foreground-subdued);
background-color: var(--background-subdued);
border-color: var(--border-subdued);
cursor: not-allowed;
}

View File

@@ -6,6 +6,7 @@
'--v-divider-color': color,
'--v-divider-label-color': color,
}"
large
>
<template v-if="icon" #icon><v-icon :name="icon" /></template>
<template v-if="title" #default>{{ title }}</template>

View File

@@ -4,16 +4,25 @@
<img :src="src" alt="" role="presentation" />
<div class="shadow" />
<div class="actions">
<v-button icon rounded @click="editorActive = true">
<v-icon name="crop_rotate" />
</v-button>
<v-button icon rounded @click="lightboxActive = true">
<v-button icon rounded @click="lightboxActive = true" v-tooltip="$t('zoom')">
<v-icon name="zoom_in" />
</v-button>
<v-button icon rounded :href="image.data.full_url" :download="image.filename_download">
<v-button
icon
rounded
:href="image.data.full_url"
:download="image.filename_download"
v-tooltip="$t('download')"
>
<v-icon name="file_download" />
</v-button>
<v-button icon rounded @click="deselect">
<v-button icon rounded @click="lightboxActive = true" v-tooltip="$t('open')">
<v-icon name="launch" />
</v-button>
<v-button icon rounded @click="editorActive = true" v-tooltip="$t('edit')">
<v-icon name="crop_rotate" />
</v-button>
<v-button icon rounded @click="deselect" v-tooltip="$t('deselect')">
<v-icon name="close" />
</v-button>
</div>
@@ -216,7 +225,7 @@ img {
line-height: 1;
white-space: nowrap;
text-overflow: ellipsis;
background: linear-gradient(180deg, rgba(38, 50, 56, 0) 0%, rgba(38, 50, 56, 0.75) 100%);
background: linear-gradient(180deg, rgba(38, 50, 56, 0) 0%, rgba(38, 50, 56, 0.25) 100%);
transition: height var(--fast) var(--transition);
}
@@ -271,13 +280,14 @@ img {
height: 17px;
max-height: 0;
overflow: hidden;
color: var(--foreground-subdued);
color: rgba(255, 255, 255, 0.75);
transition: max-height var(--fast) var(--transition);
}
.image-preview:hover {
.shadow {
height: 100%;
background: linear-gradient(180deg, rgba(38, 50, 56, 0) 0%, rgba(38, 50, 56, 0.5) 100%);
}
.actions .v-button {

View File

@@ -72,11 +72,13 @@
"settings_update_success": "Settings updated",
"settings_update_failed": "Updating settings failed",
"activity_delta_created": "Created this item",
"activity_delta_created_externally": "Created externally",
"activity_delta_updated": "Updated this item",
"activity_delta_deleted": "Deleted this item",
"revision_delta_created": "Created",
"revision_delta_created_externally": "Created Externally",
"revision_delta_updated": "Updated {count} Fields",
"revision_delta_soft_deleted": "Soft Deleted",
"revision_delta_deleted": "Deleted",
"private_user": "Private User",
"revision_unknown": "Created Outside System",
"leave_comment": "Leave a comment...",
"post_comment_success": "Comment posted",
@@ -99,8 +101,17 @@
"corresponding_field_name": "Corresponding Field Name",
"datetime": "DateTime",
"today": "Today",
"yesterday": "Yesterday",
"delete_comment": "Delete Comment",
"date-fns_datetime": "PPP h:mma",
"date-fns_date": "PPP",
"date-fns_time": "K:mm a",
"date-fns_date_short": "MMM d, u",
"date-fns_date_short_no_year": "MMM d",
"date-fns_time": "h:mma",
"month": "Month",
"date": "Date",
"year": "Year",
@@ -126,6 +137,10 @@
"set_to_now": "Set to Now",
"zoom": "Zoom",
"download": "Download",
"open": "Open",
"name": "Name",
"primary_key_field": "Primary Key Field",
"type": "Type",
@@ -151,6 +166,8 @@
"add_existing": "Add Existing",
"comments": "Comments",
"no_comments": "No Comments Yet",
"click_to_expand": "Click to Expand",
"select_item": "Select Item",
@@ -166,8 +183,8 @@
"radio_buttons": "Radio Buttons",
"checkboxes": "Checkboxes",
"relationship_not_setup": "The relationship hasn't been configured correctly.",
"display_template_not_setup": "The display template hasn't been configured correctly.",
"relationship_not_setup": "The relationship hasn't been configured correctly",
"display_template_not_setup": "The display template is misconfigured",
"choose_status": "Choose Status...",
@@ -835,7 +852,7 @@
"reset": "Reset",
"reset_password": "Reset Password",
"reset_preferences": "Reset all listing preferences",
"revisions": "Revisions",
"revert": "Revert",
"revert_copy": "Do you want to revert this item to how it was on {date}?",
"role_count": "No Roles | One Role | {count} Roles",

View File

@@ -467,6 +467,10 @@ export default defineComponent({
width: auto;
margin-right: 4px;
}
.v-select {
color: var(--foreground-normal);
}
}
}

View File

@@ -274,12 +274,26 @@ export default defineComponent({
.title,
.subtitle {
position: relative;
width: 100%;
height: 20px;
overflow: hidden;
line-height: 1.3em;
white-space: nowrap;
text-overflow: ellipsis;
&::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 12px;
background: linear-gradient(
90deg,
rgba(var(--background-page-rgb), 0) 0%,
rgba(var(--background-page-rgb), 1) 100%
);
content: '';
}
}
.title {

View File

@@ -565,6 +565,10 @@ export default defineComponent({
width: auto;
margin-right: 4px;
}
.v-select {
color: var(--foreground-normal);
}
}
}

View File

@@ -1,7 +1,7 @@
<template>
<private-view :title="$t('activity')">
<private-view :title="$t('activity_log')">
<template #title-outer:prepend>
<v-button rounded disabled icon secondary>
<v-button class="header-icon" rounded icon secondary disabled>
<v-icon name="notifications" />
</v-button>
</template>
@@ -82,6 +82,12 @@ export default defineComponent({
--v-button-background-color-hover: var(--warning-150);
}
.header-icon.secondary {
--v-button-background-color: var(--background-normal);
--v-button-color-disabled: var(--foreground-normal);
--v-button-color-activated: var(--foreground-normal);
}
.layout {
--layout-offset-top: 64px;
}

View File

@@ -1,7 +1,7 @@
<template>
<private-view :title="$t('editing', { collection: $t('activity') })">
<private-view title="Updated: Item Display Template">
<template #title-outer:prepend>
<v-button rounded icon secondary exact :to="breadcrumb[0].to">
<v-button class="header-icon" rounded icon secondary exact :to="breadcrumb[0].to">
<v-icon name="arrow_back" />
</v-button>
</template>
@@ -62,7 +62,7 @@ export default defineComponent({
function useBreadcrumb() {
const breadcrumb = computed(() => [
{
name: i18n.t('activity'),
name: i18n.t('activity_log'),
to: `/${currentProjectKey.value}/activity/`,
},
]);
@@ -79,6 +79,12 @@ export default defineComponent({
--v-button-background-color-hover: var(--danger-dark);
}
.header-icon.secondary {
--v-button-background-color: var(--background-normal);
--v-button-color-disabled: var(--foreground-normal);
--v-button-color-activated: var(--foreground-normal);
}
.v-form {
padding: var(--content-padding);
}

View File

@@ -6,7 +6,7 @@ import CollectionsItemNotFound from './routes/not-found';
export default defineModule(({ i18n }) => ({
id: 'collections',
name: i18n.tc('collection', 2),
name: i18n.t('collections'),
icon: 'box',
routes: [
{

View File

@@ -7,6 +7,10 @@
</v-button>
</template>
<template v-if="bookmark" #headline>
<v-breadcrumb :items="breadcrumb" />
</template>
<template #title-outer:append>
<bookmark-add
v-if="!bookmark"
@@ -192,6 +196,7 @@ export default defineComponent({
const { selection } = useSelection();
const { info: currentCollection, primaryKeyField } = useCollection(collection);
const { addNewLink, batchLink, collectionsLink, currentCollectionLink } = useLinks();
const { breadcrumb } = useBreadcrumb();
const {
viewType,
viewOptions,
@@ -243,12 +248,24 @@ export default defineComponent({
bookmarkName,
editingBookmark,
editBookmark,
breadcrumb,
};
function useBreadcrumb() {
const breadcrumb = computed(() => [
{
name: props.collection,
to: `/${projectsStore.state.currentProjectKey}/collections/${props.collection}`,
},
]);
return { breadcrumb };
}
function useSelection() {
const selection = ref<Item[]>([]);
// Whenever the collection changes we're working on, we have to clear the selection
// Whenever the collection we're working on changes, we have to clear the selection
watch(
() => props.collection,
() => (selection.value = [])
@@ -387,5 +404,18 @@ export default defineComponent({
.bookmark-add .toggle,
.bookmark-edit .toggle {
margin-left: 8px;
transition: color var(--fast) var(--transition);
}
.bookmark-add {
color: var(--foreground-subdued);
&:hover {
color: var(--foreground-normal);
}
}
.bookmark-edit {
color: var(--primary);
}
</style>

View File

@@ -44,6 +44,10 @@
</v-button>
</template>
<template #headline>
<v-breadcrumb :items="breadcrumb" />
</template>
<template #actions>
<v-dialog v-if="!isNew" v-model="confirmDelete">
<template #activator="{ on }">
@@ -143,7 +147,12 @@
/>
<template #drawer>
<activity-drawer-detail
<revisions-drawer-detail
v-if="isBatch === false && isNew === false"
:collection="collection"
:primary-key="primaryKey"
/>
<comments-drawer-detail
v-if="isBatch === false && isNew === false"
:collection="collection"
:primary-key="primaryKey"
@@ -159,7 +168,8 @@ import CollectionsNavigation from '../../components/navigation/';
import router from '@/router';
import CollectionsNotFound from '../not-found/';
import useCollection from '@/composables/use-collection';
import ActivityDrawerDetail from '@/views/private/components/activity-drawer-detail';
import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail';
import CommentsDrawerDetail from '@/views/private/components/comments-drawer-detail';
import useItem from '@/composables/use-item';
import SaveOptions from '@/views/private/components/save-options';
@@ -169,7 +179,13 @@ type Values = {
export default defineComponent({
name: 'collections-detail',
components: { CollectionsNavigation, CollectionsNotFound, ActivityDrawerDetail, SaveOptions },
components: {
CollectionsNavigation,
CollectionsNotFound,
RevisionsDrawerDetail,
CommentsDrawerDetail,
SaveOptions,
},
props: {
collection: {
type: String,
@@ -184,6 +200,7 @@ export default defineComponent({
const projectsStore = useProjectsStore();
const { currentProjectKey } = toRefs(projectsStore.state);
const { collection, primaryKey } = toRefs(props);
const { breadcrumb } = useBreadcrumb();
const { info: collectionInfo, softDeleteStatus, primaryKeyField } = useCollection(
collection
@@ -242,8 +259,20 @@ export default defineComponent({
isBatch,
softDeleteStatus,
templateValues,
breadcrumb,
};
function useBreadcrumb() {
const breadcrumb = computed(() => [
{
name: collectionInfo.value?.name,
to: `/${currentProjectKey.value}/collections/${props.collection}`,
},
]);
return { breadcrumb };
}
async function saveAndQuit() {
await save();
router.push(`/${currentProjectKey.value}/collections/${props.collection}`);

View File

@@ -1,5 +1,5 @@
<template>
<private-view :title="$t('files')">
<private-view :title="$t('file_library')">
<template #title-outer:prepend>
<v-button class="header-icon" rounded disabled icon secondary>
<v-icon name="folder" />

View File

@@ -1,5 +1,5 @@
<template>
<private-view :title="loading ? $t('loading') : $t('editing_file', { title: item.title })">
<private-view :title="loading ? $t('loading') : item.title">
<template #title-outer:prepend>
<v-button class="header-icon" rounded icon secondary exact :to="breadcrumb[0].to">
<v-icon name="arrow_back" />
@@ -103,7 +103,7 @@
</div>
<template #drawer>
<activity-drawer-detail
<revisions-drawer-detail
v-if="isBatch === false && isNew === false"
collection="directus_files"
:primary-key="primaryKey"
@@ -118,7 +118,7 @@ import useProjectsStore from '@/stores/projects';
import FilesNavigation from '../../components/navigation/';
import { i18n } from '@/lang';
import router from '@/router';
import ActivityDrawerDetail from '@/views/private/components/activity-drawer-detail';
import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail';
import useItem from '@/composables/use-item';
import SaveOptions from '@/views/private/components/save-options';
import FilePreview from '@/views/private/components/file-preview';
@@ -134,7 +134,7 @@ export default defineComponent({
name: 'files-detail',
components: {
FilesNavigation,
ActivityDrawerDetail,
RevisionsDrawerDetail,
SaveOptions,
FilePreview,
ImageEditor,
@@ -206,7 +206,7 @@ export default defineComponent({
function useBreadcrumb() {
const breadcrumb = computed(() => [
{
name: i18n.t('files'),
name: i18n.t('file_library'),
to: `/${currentProjectKey.value}/files/`,
},
]);

View File

@@ -6,10 +6,6 @@
</v-button>
</template>
<template #headline>
<v-breadcrumb :items="breadcrumb" />
</template>
<template #actions>
<v-dialog v-model="confirmDelete">
<template #activator="{ on }">
@@ -80,7 +76,6 @@ import SettingsNavigation from '../../../components/navigation/';
import useCollection from '@/composables/use-collection/';
import FieldsManagement from './components/fields-management';
import useProjectsStore from '@/stores/projects';
import { i18n } from '@/lang';
import useItem from '@/composables/use-item';
import router from '@/router';
import useCollectionsStore from '@/stores/collections';
@@ -100,15 +95,6 @@ export default defineComponent({
const { currentProjectKey } = toRefs(projectsStore.state);
const collectionsStore = useCollectionsStore();
const breadcrumb = computed(() => {
return [
{
name: i18n.t('settings_data_model'),
to: `/${projectsStore.state.currentProjectKey}/settings/data-model`,
},
];
});
const {
isNew,
edits,
@@ -130,7 +116,6 @@ export default defineComponent({
return {
collectionInfo,
fields,
breadcrumb,
confirmDelete,
isNew,
edits,

View File

@@ -7,15 +7,9 @@
</template>
<template #actions>
<v-dialog v-model="confirmDelete">
<v-dialog v-model="confirmDelete" v-if="selection.length > 0">
<template #activator="{ on }">
<v-button
rounded
icon
class="action-delete"
@click="on"
:disabled="selection.length === 0"
>
<v-button rounded icon class="action-delete" @click="on">
<v-icon name="delete" />
</v-button>
</template>

View File

@@ -1,15 +1,17 @@
<template>
<private-view :title="$t('editing', { collection: $t('roles') })">
<template #title-outer:prepend>
<v-button class="header-icon" rounded icon exact :to="breadcrumb[0].to">
<v-button
class="header-icon"
rounded
icon
exact
:to="`/${currentProjectKey}/settings/roles/`"
>
<v-icon name="arrow_back" />
</v-button>
</template>
<template #headline>
<v-breadcrumb :items="breadcrumb" />
</template>
<template #actions>
<v-dialog v-model="confirmDelete">
<template #activator="{ on }">
@@ -80,7 +82,7 @@
</div>
<template #drawer>
<activity-drawer-detail
<revisions-drawer-detail
v-if="isNew === false"
collection="directus_roles"
:primary-key="primaryKey"
@@ -93,9 +95,8 @@
import { defineComponent, computed, toRefs, ref } from '@vue/composition-api';
import useProjectsStore from '@/stores/projects';
import SettingsNavigation from '../../../components/navigation/';
import { i18n } from '@/lang';
import router from '@/router';
import ActivityDrawerDetail from '@/views/private/components/activity-drawer-detail';
import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail';
import useItem from '@/composables/use-item';
import SaveOptions from '@/views/private/components/save-options';
import PermissionsManagement from './components/permissions-management';
@@ -106,7 +107,7 @@ type Values = {
export default defineComponent({
name: 'roles-detail',
components: { SettingsNavigation, ActivityDrawerDetail, SaveOptions, PermissionsManagement },
components: { SettingsNavigation, RevisionsDrawerDetail, SaveOptions, PermissionsManagement },
props: {
primaryKey: {
type: String,
@@ -117,7 +118,6 @@ export default defineComponent({
const projectsStore = useProjectsStore();
const { currentProjectKey } = toRefs(projectsStore.state);
const { primaryKey } = toRefs(props);
const { breadcrumb } = useBreadcrumb();
const {
isNew,
@@ -142,7 +142,6 @@ export default defineComponent({
loading,
error,
isNew,
breadcrumb,
edits,
hasEdits,
saving,
@@ -154,19 +153,9 @@ export default defineComponent({
saveAndAddNew,
saveAsCopyAndNavigate,
isBatch,
currentProjectKey,
};
function useBreadcrumb() {
const breadcrumb = computed(() => [
{
name: i18n.t('roles'),
to: `/${currentProjectKey.value}/settings/roles/`,
},
]);
return { breadcrumb };
}
async function saveAndQuit() {
await save();
router.push(`/${currentProjectKey.value}/settings/roles`);

View File

@@ -1,15 +1,17 @@
<template>
<private-view :title="$t('editing', { collection: $t('webhooks') })">
<template #title-outer:prepend>
<v-button class="header-icon" rounded icon exact :to="breadcrumb[0].to">
<v-button
class="header-icon"
rounded
icon
exact
:to="`/${currentProjectKey}/settings/webhooks/`"
>
<v-icon name="arrow_back" />
</v-button>
</template>
<template #headline>
<v-breadcrumb :items="breadcrumb" />
</template>
<template #actions>
<v-dialog v-model="confirmDelete">
<template #activator="{ on }">
@@ -72,7 +74,7 @@
/>
<template #drawer>
<activity-drawer-detail
<revisions-drawer-detail
v-if="isNew === false"
collection="directus_webhooks"
:primary-key="primaryKey"
@@ -85,9 +87,8 @@
import { defineComponent, computed, toRefs, ref } from '@vue/composition-api';
import useProjectsStore from '@/stores/projects';
import SettingsNavigation from '../../../components/navigation/';
import { i18n } from '@/lang';
import router from '@/router';
import ActivityDrawerDetail from '@/views/private/components/activity-drawer-detail';
import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail';
import useItem from '@/composables/use-item';
import SaveOptions from '@/views/private/components/save-options';
@@ -97,7 +98,7 @@ type Values = {
export default defineComponent({
name: 'webhooks-detail',
components: { SettingsNavigation, ActivityDrawerDetail, SaveOptions },
components: { SettingsNavigation, RevisionsDrawerDetail, SaveOptions },
props: {
primaryKey: {
type: String,
@@ -108,7 +109,6 @@ export default defineComponent({
const projectsStore = useProjectsStore();
const { currentProjectKey } = toRefs(projectsStore.state);
const { primaryKey } = toRefs(props);
const { breadcrumb } = useBreadcrumb();
const {
isNew,
@@ -133,7 +133,6 @@ export default defineComponent({
loading,
error,
isNew,
breadcrumb,
edits,
hasEdits,
saving,
@@ -145,19 +144,9 @@ export default defineComponent({
saveAndAddNew,
saveAsCopyAndNavigate,
isBatch,
currentProjectKey,
};
function useBreadcrumb() {
const breadcrumb = computed(() => [
{
name: i18n.t('webhooks'),
to: `/${currentProjectKey.value}/settings/webhooks/`,
},
]);
return { breadcrumb };
}
async function saveAndQuit() {
await save();
router.push(`/${currentProjectKey.value}/settings/webhooks`);

View File

@@ -1,5 +1,5 @@
<template>
<private-view :title="$t('users')">
<private-view :title="$t('user_directory')">
<template #title-outer:prepend>
<v-button class="header-icon" rounded disabled icon secondary>
<v-icon name="people" />

View File

@@ -1,5 +1,5 @@
<template>
<private-view :title="$t('editing', { collection: $t('users') })">
<private-view :title="loading ? $t('loading') : item.first_name + ' ' + item.last_name">
<template #title-outer:prepend>
<v-button class="header-icon" rounded icon secondary exact :to="breadcrumb[0].to">
<v-icon name="arrow_back" />
@@ -72,7 +72,7 @@
/>
<template #drawer>
<activity-drawer-detail
<revisions-drawer-detail
v-if="isBatch === false && isNew === false"
collection="directus_users"
:primary-key="primaryKey"
@@ -87,7 +87,7 @@ import useProjectsStore from '@/stores/projects';
import UsersNavigation from '../../components/navigation/';
import { i18n } from '@/lang';
import router from '@/router';
import ActivityDrawerDetail from '@/views/private/components/activity-drawer-detail';
import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail';
import useItem from '@/composables/use-item';
import SaveOptions from '@/views/private/components/save-options';
@@ -97,7 +97,7 @@ type Values = {
export default defineComponent({
name: 'users-detail',
components: { UsersNavigation, ActivityDrawerDetail, SaveOptions },
components: { UsersNavigation, RevisionsDrawerDetail, SaveOptions },
props: {
primaryKey: {
type: String,
@@ -150,7 +150,7 @@ export default defineComponent({
function useBreadcrumb() {
const breadcrumb = computed(() => [
{
name: i18n.t('users'),
name: i18n.t('user_directory'),
to: `/${currentProjectKey.value}/users/`,
},
]);

View File

@@ -14,6 +14,7 @@
--background-subdued: #F5F7F8;
--background-highlight: #F9FAFB;
--background-page: #FFF;
--background-page-rgb: 255, 255, 255;
--background-inverted: #263238;
--primary-10: #EAF2FD;

View File

@@ -1,279 +0,0 @@
<template>
<drawer-detail :title="$t('comments')" icon="mode_comment">
<form @submit.prevent="postComment">
<v-textarea
:placeholder="$t('leave_comment')"
v-model="newCommentContent"
expand-on-focus
>
<template #append>
<v-button
:disabled="!newCommentContent || newCommentContent.length === 0"
:loading="saving"
class="post-comment"
@click="postComment"
x-small
>
{{ $t('submit') }}
</v-button>
</template>
</v-textarea>
</form>
<transition-group name="slide" tag="div">
<div class="activity-record" v-for="act in activity" :key="act.id">
<div class="activity-header">
<v-avatar small>
<v-icon name="person_outline" />
</v-avatar>
<div class="header-content">
<div class="name">
<template v-if="act.action_by && act.action_by">
{{ act.action_by.first_name }} {{ act.action_by.last_name }}
</template>
<template v-else-if="act.action.by && action.action_by">
{{ $t('private_user') }}
</template>
<template v-else>
{{ $t('external') }}
</template>
</div>
<div class="date" v-tooltip.start="new Date(act.action_on)">
{{ act.date_relative }}
</div>
</div>
</div>
<div class="content">
<template v-if="act.comment">
<span v-html="marked(act.comment)" />
</template>
<template v-else-if="act.action === 'create'">
{{ $t('activity_delta_created') }}
</template>
<template v-else-if="act.action === 'update'">
{{ $t('activity_delta_updated') }}
</template>
<template v-else-if="act.action === 'delete'">
{{ $t('activity_delta_deleted') }}
</template>
</div>
</div>
</transition-group>
<div class="activity-record">
<div class="content">{{ $t('activity_delta_created_externally') }}</div>
</div>
</drawer-detail>
</template>
<script lang="ts">
import { defineComponent, ref } from '@vue/composition-api';
import useProjectsStore from '@/stores/projects';
import api from '@/api';
import localizedFormatDistance from '@/utils/localized-format-distance';
import marked from 'marked';
import { Avatar } from '@/stores/user/types';
import notify from '@/utils/notify';
import i18n from '@/lang';
type Activity = {
action: 'create' | 'update' | 'delete' | 'comment';
action_by: null | {
id: number;
first_name: string;
last_name: string;
avatar: null | Avatar;
};
action_on: string;
edited_on: null | string;
comment: null | string;
};
export default defineComponent({
props: {
collection: {
type: String,
required: true,
},
primaryKey: {
type: [String, Number],
required: true,
},
},
setup(props) {
const projectsStore = useProjectsStore();
const { activity, loading, error, refresh } = useActivity(
props.collection,
props.primaryKey
);
const { newCommentContent, postComment, saving } = useComment();
return { activity, loading, error, marked, newCommentContent, postComment, saving };
function useActivity(collection: string, primaryKey: string | number) {
const activity = ref<Activity[]>(null);
const error = ref(null);
const loading = ref(false);
getActivity();
return { activity, error, loading, refresh };
async function getActivity() {
error.value = null;
loading.value = true;
try {
const response = await api.get(
`/${projectsStore.state.currentProjectKey}/activity`,
{
params: {
'filter[collection][eq]': collection,
'filter[item][eq]': primaryKey,
'filter[action][in]': 'comment,create,update,delete',
sort: '-id', // directus_activity has auto increment and is therefore in chronological order
fields: [
'id',
'action',
'action_on',
'action_by.id',
'action_by.first_name',
'action_by.last_name',
'action_by.avatar.data',
'comment',
],
},
}
);
const records = [];
for (const record of response.data.data) {
records.push({
...record,
date_relative: await localizedFormatDistance(
new Date(record.action_on),
new Date(),
{
addSuffix: true,
}
),
});
}
activity.value = records;
} catch (error) {
error.value = error;
} finally {
loading.value = false;
}
}
async function refresh() {
await getActivity();
}
}
function useComment() {
const newCommentContent = ref(null);
const saving = ref(false);
return { newCommentContent, postComment, saving };
async function postComment() {
saving.value = true;
try {
await api.post(`/${projectsStore.state.currentProjectKey}/activity/comment`, {
collection: props.collection,
item: props.primaryKey,
comment: newCommentContent.value,
});
await refresh();
newCommentContent.value = null;
notify({
title: i18n.t('post_comment_success'),
type: 'success',
});
} catch {
notify({
title: i18n.t('post_comment_failed'),
type: 'error',
});
} finally {
saving.value = false;
}
}
}
},
});
</script>
<style lang="scss" scoped>
.post-comment {
position: absolute;
right: 8px;
bottom: 8px;
}
.activity-record {
margin-top: 12px;
padding-top: 8px;
border-top: 2px solid var(--border-normal);
}
.activity-header {
display: flex;
align-items: center;
margin-bottom: 8px;
.v-avatar {
--v-avatar-color: var(--background-normal-alt);
margin-right: 8px;
.v-icon {
--v-icon-color: var(--foreground-subdued);
}
}
.name,
.date {
line-height: 1.25;
}
.name {
margin-right: 8px;
}
.date {
color: var(--foreground-subdued);
}
}
.content {
font-style: italic;
}
.slide-enter-active,
.slide-leave-active {
transition: all var(--slow) var(--transition);
}
.slide-leave-active {
position: absolute;
}
.slide-move {
transition: all 500ms var(--transition);
}
.slide-enter,
.slide-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,4 +0,0 @@
import ActivityDrawerDetail from './activity-drawer-detail.vue';
export { ActivityDrawerDetail };
export default ActivityDrawerDetail;

View File

@@ -0,0 +1,151 @@
<template>
<v-textarea
class="new-comment"
:placeholder="$t('leave_comment')"
v-model="newCommentContent"
expand-on-focus
>
<template #append>
<v-icon name="alternate_email" class="add-mention" />
<v-icon name="insert_emoticon" class="add-emoji" />
<v-button
:disabled="!newCommentContent || newCommentContent.length === 0"
:loading="saving"
class="post-comment"
@click="postComment"
x-small
>
{{ $t('submit') }}
</v-button>
</template>
</v-textarea>
</template>
<script lang="ts">
import { defineComponent, ref } from '@vue/composition-api';
import useProjectsStore from '@/stores/projects';
import notify from '@/utils/notify';
import api from '@/api';
import i18n from '@/lang';
export default defineComponent({
props: {
refresh: {
type: Function,
required: true,
},
collection: {
type: String,
required: true,
},
primaryKey: {
type: [Number, String],
required: true,
},
},
setup(props) {
const projectsStore = useProjectsStore();
const newCommentContent = ref(null);
const saving = ref(false);
return { newCommentContent, postComment, saving };
async function postComment() {
saving.value = true;
try {
await api.post(`/${projectsStore.state.currentProjectKey}/activity/comment`, {
collection: props.collection,
item: props.primaryKey,
comment: newCommentContent.value,
});
await props.refresh();
newCommentContent.value = null;
notify({
title: i18n.t('post_comment_success'),
type: 'success',
});
} catch {
notify({
title: i18n.t('post_comment_failed'),
type: 'error',
});
} finally {
saving.value = false;
}
}
},
});
</script>
<style lang="scss" scoped>
.new-comment {
&::v-deep {
&.expand-on-focus {
textarea {
position: relative;
transition: margin-bottom var(--fast) var(--transition);
}
.append {
&::after {
position: absolute;
right: 0;
bottom: 36px;
left: 0;
height: 8px;
background: linear-gradient(
180deg,
rgba(var(--background-page-rgb), 0) 0%,
rgba(var(--background-page-rgb), 1) 100%
);
content: '';
}
}
&:focus,
&:focus-within,
&.has-content {
textarea {
margin-bottom: 36px;
}
}
}
}
.add-mention {
position: absolute;
bottom: 8px;
left: 8px;
color: var(--foreground-subdued);
cursor: pointer;
transition: color var(--fast) var(--transition);
}
.add-emoji {
position: absolute;
bottom: 8px;
left: 36px;
color: var(--foreground-subdued);
cursor: pointer;
transition: color var(--fast) var(--transition);
}
.add-mention,
.add-emoji {
&:hover {
color: var(--primary);
}
}
.post-comment {
position: absolute;
right: 8px;
bottom: 8px;
}
}
</style>

View File

@@ -0,0 +1,232 @@
<template>
<div class="comment-header">
<v-avatar x-small>
<img
v-if="avatarSource"
:src="avatarSource"
:alt="activity.action_by.first_name + ' ' + activity.action_by.last_name"
/>
<v-icon v-else name="person_outline" />
</v-avatar>
<div class="name">
<template v-if="activity.action_by && activity.action_by">
{{ activity.action_by.first_name }} {{ activity.action_by.last_name }}
</template>
<template v-else-if="activity.action_by && action.action_by">
{{ $t('private_user') }}
</template>
</div>
<div class="header-right">
<v-menu show-arrow placement="bottom-end" close-on-content-click>
<template #activator="{ toggle, active }">
<v-icon class="more" :class="{ active }" name="more_horiz" @click="toggle" />
<div class="time">
<span
class="dot"
v-if="activity.edited_on !== null"
v-tooltip="editedOnFormatted"
/>
{{ formattedTime }}
</div>
</template>
<v-list dense>
<v-list-item @click="$emit('edit')">
<v-list-item-icon><v-icon name="edit" /></v-list-item-icon>
<v-list-item-content>{{ $t('edit') }}</v-list-item-content>
</v-list-item>
<v-list-item @click="confirmDelete = true">
<v-list-item-icon><v-icon name="delete" /></v-list-item-icon>
<v-list-item-content>{{ $t('delete') }}</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</div>
<v-dialog v-model="confirmDelete">
<v-card>
<v-card-title>{{ $t('delete_comment') }}</v-card-title>
<v-card-text>{{ $t('delete_are_you_sure') }}</v-card-text>
<v-card-actions>
<v-button @click="confirmDelete = false" secondary>
{{ $t('cancel') }}
</v-button>
<v-button @click="remove" class="action-delete" :loading="deleting">
{{ $t('delete') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed, ref, watch } from '@vue/composition-api';
import { Activity } from './types';
import format from 'date-fns/format';
import i18n from '@/lang';
import useProjectsStore from '@/stores/projects';
import api from '@/api';
import localizedFormat from '@/utils/localized-format';
export default defineComponent({
props: {
activity: {
type: Object as PropType<Activity>,
required: true,
},
refresh: {
type: Function,
required: true,
},
},
setup(props) {
const editedOnFormatted = ref('');
watch(
() => props.activity,
async () => {
if (props.activity.edited_on) {
editedOnFormatted.value = await localizedFormat(
new Date(props.activity.edited_on),
String(i18n.t('date-fns_datetime'))
);
}
}
);
const projectsStore = useProjectsStore();
const formattedTime = computed(() => {
if (props.activity.action_on) {
// action_on is in iso-8601
return format(new Date(props.activity.action_on), String(i18n.t('date-fns_time')));
}
return null;
});
const avatarSource = computed(() => {
if (!props.activity.action_by?.avatar) return null;
return (
props.activity.action_by.avatar.data.thumbnails.find(
(thumb) => thumb.key === 'directus-small-crop'
)?.url || null
);
});
const { confirmDelete, deleting, remove } = useDelete();
return { formattedTime, avatarSource, confirmDelete, deleting, remove, editedOnFormatted };
function useDelete() {
const { currentProjectKey } = projectsStore.state;
const confirmDelete = ref(false);
const deleting = ref(false);
return { confirmDelete, deleting, remove };
async function remove() {
deleting.value = true;
try {
await api.delete(`/${currentProjectKey}/activity/comment/${props.activity.id}`);
await props.refresh();
confirmDelete.value = false;
} catch (error) {
console.error(error);
} finally {
deleting.value = false;
}
}
}
},
});
</script>
<style lang="scss" scoped>
.comment-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.v-avatar {
--v-avatar-color: var(--background-normal-alt);
flex-basis: 24px;
margin-right: 8px;
.v-icon {
--v-icon-color: var(--foreground-subdued);
}
}
.name {
flex-grow: 1;
margin-right: 8px;
font-weight: 600;
}
.header-right {
position: relative;
flex-basis: 24px;
color: var(--foreground-subdued);
.more {
cursor: pointer;
opacity: 0;
transition: all var(--slow) var(--transition);
&:hover {
color: var(--foreground-normal);
}
&.active {
opacity: 1;
}
}
.time {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
font-size: 12px;
text-align: right;
text-transform: lowercase;
opacity: 1;
transition: opacity var(--slow) var(--transition);
pointer-events: none;
}
.more.active + .time {
opacity: 0;
}
}
}
.action-delete {
--v-button-background-color: var(--danger-25);
--v-button-color: var(--danger);
--v-button-background-color-hover: var(--danger-50);
--v-button-color-hover: var(--danger);
}
.dot {
display: inline-block;
width: 6px;
height: 6px;
margin-right: 4px;
vertical-align: middle;
background-color: var(--warning);
border-radius: 3px;
}
</style>

View File

@@ -0,0 +1,204 @@
<template>
<div class="comment-item">
<comment-item-header :refresh="refresh" :activity="activity" @edit="editing = true" />
<v-textarea v-if="editing" v-model="edits">
<template #append>
<v-button :loading="savingEdits" class="post-comment" @click="saveEdits" x-small>
{{ $t('save') }}
</v-button>
</template>
</v-textarea>
<div v-else class="content">
<span v-html="htmlContent" />
<!-- @TODO: Dynamically add element below if the comment overflows -->
<!-- <div v-if="activity.id == 204" class="expand-text">
<span>{{ $t('click_to_expand') }}</span>
</div> -->
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, ref, computed, watch } from '@vue/composition-api';
import { Activity } from './types';
import CommentItemHeader from './comment-item-header.vue';
import marked from 'marked';
import useProjectsStore from '@/stores/projects';
import api from '@/api';
export default defineComponent({
components: { CommentItemHeader },
props: {
activity: {
type: Object as PropType<Activity>,
required: true,
},
refresh: {
type: Function,
required: true,
},
},
setup(props) {
const projectsStore = useProjectsStore();
const htmlContent = computed(() =>
props.activity.comment ? marked(props.activity.comment) : null
);
const { edits, editing, savingEdits, saveEdits } = useEdits();
return { htmlContent, edits, editing, savingEdits, saveEdits };
function useEdits() {
const edits = ref(props.activity.comment);
const editing = ref(false);
const savingEdits = ref(false);
watch(
() => props.activity,
() => (edits.value = props.activity.comment)
);
return { edits, editing, savingEdits, saveEdits };
async function saveEdits() {
const { currentProjectKey } = projectsStore.state;
savingEdits.value = true;
try {
await api.patch(`/${currentProjectKey}/activity/comment/${props.activity.id}`, {
comment: edits.value,
});
await props.refresh();
} catch (error) {
console.error(error);
} finally {
savingEdits.value = false;
editing.value = false;
}
}
}
},
});
</script>
<style lang="scss" scoped>
.comment-item {
position: relative;
margin-bottom: 16px;
padding: 8px;
background-color: var(--background-page);
border-radius: var(--border-radius);
.content {
max-height: 300px;
overflow-y: hidden;
::v-deep {
a {
color: var(--primary);
}
blockquote {
margin: 8px 0;
padding-left: 6px;
color: var(--foreground-subdued);
font-style: italic;
border-left: 2px solid var(--border-normal);
}
img {
max-width: 100%;
margin: 8px 0;
}
hr {
height: 2px;
margin: 12px 0;
border: 0;
border-top: 2px solid var(--border-normal);
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 12px;
font-weight: 600;
font-size: 16px;
}
}
}
&.expand {
.content {
&::after {
position: absolute;
right: 0;
bottom: 4px;
left: 0;
z-index: 1;
height: 40px;
background: linear-gradient(
180deg,
rgba(var(--background-page-rgb), 0) 0%,
rgba(var(--background-page-rgb), 0.8) 25%,
rgba(var(--background-page-rgb), 1) 100%
);
content: '';
}
.expand-text {
position: absolute;
right: 0;
bottom: 8px;
left: 0;
z-index: 2;
height: 24px;
text-align: center;
cursor: pointer;
span {
padding: 4px 12px 5px;
color: var(--foreground-subdued);
font-weight: 600;
font-size: 12px;
background-color: var(--background-normal);
border-radius: 12px;
transition: color var(--fast) var(--transition),
background-color var(--fast) var(--transition);
}
&:hover span {
color: var(--foreground-inverted);
background-color: var(--primary);
}
}
}
}
&:hover {
::v-deep .comment-header {
.header-right {
.time {
opacity: 0;
}
.more {
opacity: 1;
}
}
}
}
}
.post-comment {
position: absolute;
right: 8px;
bottom: 8px;
}
</style>

View File

@@ -0,0 +1,175 @@
<template>
<drawer-detail :title="$t('comments')" icon="chat_bubble_outline">
<comment-input :refresh="refresh" :collection="collection" :primary-key="primaryKey" />
<v-progress-linear indeterminate v-if="loading" />
<div v-else-if="!activity || activity.length === 0" class="empty">
<div class="content">{{ $t('no_comments') }}</div>
</div>
<template v-else v-for="group in activity">
<v-divider :key="group.date.toString()">{{ group.dateFormatted }}</v-divider>
<template v-for="item in group.activity">
<comment-item :refresh="refresh" :activity="item" :key="item.id" />
</template>
</template>
</drawer-detail>
</template>
<script lang="ts">
import { defineComponent, ref } from '@vue/composition-api';
import useProjectsStore from '@/stores/projects';
import api from '@/api';
import { Activity, ActivityByDate } from './types';
import CommentInput from './comment-input.vue';
import { groupBy } from 'lodash';
import i18n from '@/lang';
import formatLocalized from '@/utils/localized-format';
import { isToday, isYesterday, isThisYear } from 'date-fns';
import { TranslateResult } from 'vue-i18n';
import CommentItem from './comment-item.vue';
import { orderBy } from 'lodash';
export default defineComponent({
components: { CommentInput, CommentItem },
props: {
collection: {
type: String,
required: true,
},
primaryKey: {
type: [String, Number],
required: true,
},
},
setup(props) {
const projectsStore = useProjectsStore();
const { activity, loading, error, refresh } = useActivity(
props.collection,
props.primaryKey
);
return {
activity,
loading,
error,
refresh,
};
function useActivity(collection: string, primaryKey: string | number) {
const activity = ref<ActivityByDate[]>(null);
const error = ref(null);
const loading = ref(false);
getActivity();
return { activity, error, loading, refresh };
async function getActivity() {
error.value = null;
loading.value = true;
try {
const response = await api.get(
`/${projectsStore.state.currentProjectKey}/activity`,
{
params: {
'filter[collection][eq]': collection,
'filter[item][eq]': primaryKey,
'filter[action][in]': 'comment',
'filter[comment_deleted_on][null]': 1,
sort: '-id', // directus_activity has auto increment and is therefore in chronological order
fields: [
'id',
'action',
'action_on',
'action_by.id',
'action_by.first_name',
'action_by.last_name',
'action_by.avatar.data',
'comment',
'edited_on',
],
},
}
);
const activityByDate = groupBy(response.data.data, (activity: Activity) => {
// activity's action_on date is in iso-8601
const date = new Date(new Date(activity.action_on).toDateString());
return date;
});
const activityGrouped: ActivityByDate[] = [];
for (const [key, value] of Object.entries(activityByDate)) {
const date = new Date(key);
const today = isToday(date);
const yesterday = isYesterday(date);
const thisYear = isThisYear(date);
let dateFormatted: TranslateResult;
if (today) dateFormatted = i18n.t('today');
else if (yesterday) dateFormatted = i18n.t('yesterday');
else if (thisYear)
dateFormatted = await formatLocalized(
date,
String(i18n.t('date-fns_date_short_no_year'))
);
else
dateFormatted = await formatLocalized(
date,
String(i18n.t('date-fns_date_short'))
);
activityGrouped.push({
date: date,
dateFormatted: String(dateFormatted),
activity: value,
});
}
activity.value = orderBy(activityGrouped, ['date'], ['desc']);
} catch (error) {
error.value = error;
} finally {
loading.value = false;
}
}
async function refresh() {
await getActivity();
}
}
},
});
</script>
<style lang="scss" scoped>
.v-progress-linear {
margin: 24px 0;
}
.v-divider {
position: sticky;
top: 0;
z-index: 2;
margin-top: 8px;
margin-bottom: 8px;
padding-top: 8px;
padding-bottom: 8px;
background-color: var(--background-normal);
box-shadow: 0 0 4px 2px var(--background-normal);
}
.empty {
margin-top: 16px;
margin-bottom: 16px;
color: var(--foreground-subdued);
font-style: italic;
}
</style>

View File

@@ -0,0 +1,4 @@
import CommentsDrawerDetail from './comments-drawer-detail.vue';
export { CommentsDrawerDetail };
export default CommentsDrawerDetail;

View File

@@ -0,0 +1,9 @@
# Comments Drawer
Renders an comment timeline in a drawer section meant to be used in the drawer sidebar.
## Usage
```html
<comments-drawer-detail collection="authors" primary-key="15" />
```

View File

@@ -0,0 +1,21 @@
import { Avatar } from '@/stores/user/types';
export type Activity = {
id: number;
action: 'comment';
action_by: null | {
id: number;
first_name: string;
last_name: string;
avatar: null | Avatar;
};
action_on: string;
edited_on: null | string;
comment: null | string;
};
export type ActivityByDate = {
date: Date;
dateFormatted: string;
activity: Activity[];
};

View File

@@ -95,7 +95,7 @@ body {
}
.content {
padding: 20px;
padding: 16px;
}
}
</style>

View File

@@ -16,7 +16,7 @@
@click="_active = false"
/>
<v-button class="close" @click="_active = false" icon rounded secondary>
<v-button class="close" @click="_active = false" icon rounded>
<v-icon name="close" />
</v-button>
</v-dialog>
@@ -117,6 +117,11 @@ export default defineComponent({
}
.close {
--v-button-background-color: var(--white);
--v-button-color: var(--foreground-subdued);
--v-button-background-color-hover: var(--white);
--v-button-color-hover: var(--foreground-normal);
position: absolute;
top: 32px;
right: 32px;

View File

@@ -2,6 +2,7 @@
<div class="file-preview" v-if="type">
<div v-if="type === 'image'" class="image" :class="{ svg: isSVG }" @click="$emit('click')">
<img :src="src" :width="width" :height="height" :alt="title" />
<v-icon name="fullscreen" />
</div>
<video v-else-if="type === 'video'" controls :src="src" />
@@ -71,17 +72,6 @@ export default defineComponent({
margin-bottom: var(--form-vertical-gap);
}
.image {
width: 100%;
height: 100%;
}
.svg {
padding: 64px;
background-color: var(--background-subdued);
border-radius: var(--border-radius);
}
img,
video,
audio {
@@ -91,4 +81,38 @@ audio {
object-fit: contain;
border-radius: var(--border-radius);
}
.image {
width: 100%;
height: 100%;
cursor: pointer;
img {
z-index: 1;
display: block;
}
.v-icon {
position: absolute;
right: 12px;
bottom: 12px;
z-index: 2;
color: white;
text-shadow: 0px 0px 4px rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity var(--fast) var(--transition);
}
&:hover {
.v-icon {
opacity: 1;
}
}
}
.svg {
padding: 64px;
background-color: var(--background-subdued);
border-radius: var(--border-radius);
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<header class="header-bar" ref="headerEl" :class="{ shadow: showShadow }">
<header class="header-bar" ref="headerEl" :class="{ collapsed: collapsed }">
<v-button secondary class="nav-toggle" icon rounded @click="$emit('toggle:nav')">
<v-icon name="menu" />
</v-button>
@@ -9,7 +9,9 @@
</div>
<div class="title-container">
<slot name="headline" />
<div class="headline">
<slot name="headline" />
</div>
<div class="title">
<slot name="title">
<slot name="title:prepend" />
@@ -46,11 +48,11 @@ export default defineComponent({
setup() {
const headerEl = ref<Element>();
const showShadow = ref(false);
const collapsed = ref(false);
const observer = new IntersectionObserver(
([e]) => {
showShadow.value = e.intersectionRatio < 1;
collapsed.value = e.intersectionRatio < 1;
},
{ threshold: [1] }
);
@@ -63,7 +65,7 @@ export default defineComponent({
observer.disconnect();
});
return { headerEl, showShadow };
return { headerEl, collapsed };
},
});
</script>
@@ -87,10 +89,6 @@ export default defineComponent({
box-shadow: 0;
transition: box-shadow var(--medium) var(--transition);
&.shadow {
box-shadow: 0 4px 7px -4px rgba(0, 0, 0, 0.2);
}
.nav-toggle {
@include breakpoint(medium) {
display: none;
@@ -106,8 +104,17 @@ export default defineComponent({
}
.title-container {
position: relative;
margin-left: 12px;
.headline {
position: absolute;
top: -20px;
left: 0;
opacity: 1;
transition: opacity var(--fast) var(--transition);
}
.title {
position: relative;
display: flex;
@@ -119,6 +126,17 @@ export default defineComponent({
}
}
&.collapsed {
box-shadow: 0 4px 7px -4px rgba(0, 0, 0, 0.2);
.title-container {
.headline {
opacity: 0;
pointer-events: none;
}
}
}
.spacer {
flex-grow: 1;
}

View File

@@ -43,7 +43,7 @@ export default defineComponent({
right: 8px;
left: 8px;
z-index: 50;
width: 280px;
width: 260px;
direction: rtl;
> *,

View File

@@ -0,0 +1,4 @@
import RevisionsDrawerDetail from './revisions-drawer-detail.vue';
export { RevisionsDrawerDetail };
export default RevisionsDrawerDetail;

View File

@@ -0,0 +1,370 @@
<template>
<drawer-detail :title="$t('revisions')" icon="change_history">
<!-- "Today", "Yesterday", and then: "Sep 6", "Sep 6, 2019" (previous years) -->
<div class="day-break"><span>Today</span></div>
<transition-group name="slide" tag="div">
<div v-for="act in activity" :key="act.id" class="revision internal">
<div class="header">
<template v-if="act.action === 'create'">
<span class="dot create"></span>
{{ $t('revision_delta_created') }}
</template>
<template v-else-if="act.action === 'update'">
<span class="dot update"></span>
{{ $t('revision_delta_updated', { count: 3 }) }}
</template>
<template v-else-if="act.action === 'soft-delete'">
<span class="dot delete"></span>
{{ $t('revision_delta_soft_deleted') }}
</template>
<template v-else-if="act.action === 'delete'">
<span class="dot delete"></span>
{{ $t('revision_delta_deleted') }}
</template>
<v-icon name="expand_more" class="more" />
</div>
<div class="content">
<span class="time">{{ getFormattedTime(act.action_on) }}</span>
<span class="name">
<template v-if="act.action_by && act.action_by">
{{ act.action_by.first_name }} {{ act.action_by.last_name }}
</template>
<template v-else-if="act.action_by && action.action_by">
{{ $t('private_user') }}
</template>
</span>
<div v-if="act.comment" class="comment" v-html="marked(act.comment)" />
</div>
</div>
</transition-group>
<!-- Only show this if "created" revision doesn't exist for this item -->
<div class="revision external">
<div class="header">
<span class="dot"></span>
{{ $t('revision_delta_created_externally') }}
</div>
<div class="content">{{ $t('revision_unknown') }}</div>
</div>
</drawer-detail>
</template>
<script lang="ts">
import { defineComponent, ref } from '@vue/composition-api';
import useProjectsStore from '@/stores/projects';
import api from '@/api';
import localizedFormatDistance from '@/utils/localized-format-distance';
import marked from 'marked';
import { Avatar } from '@/stores/user/types';
import notify from '@/utils/notify';
import format from 'date-fns/format';
import i18n from '@/lang';
type Activity = {
action: 'create' | 'update' | 'soft-delete' | 'delete';
action_by: null | {
id: number;
first_name: string;
last_name: string;
avatar: null | Avatar;
};
action_on: string;
edited_on: null | string;
comment: null | string;
};
export default defineComponent({
props: {
collection: {
type: String,
required: true,
},
primaryKey: {
type: [String, Number],
required: true,
},
},
setup(props) {
const projectsStore = useProjectsStore();
const { activity, loading, error, refresh } = useActivity(
props.collection,
props.primaryKey
);
const { newCommentContent, postComment, saving } = useComment();
return {
activity,
loading,
error,
marked,
newCommentContent,
postComment,
saving,
getFormattedTime,
};
function getFormattedTime(datetime: string) {
return format(new Date(datetime), String(i18n.t('date-fns_time')));
}
function useActivity(collection: string, primaryKey: string | number) {
const activity = ref<Activity[]>(null);
const error = ref(null);
const loading = ref(false);
getActivity();
return { activity, error, loading, refresh };
async function getActivity() {
error.value = null;
loading.value = true;
try {
const response = await api.get(
`/${projectsStore.state.currentProjectKey}/activity`,
{
params: {
'filter[collection][eq]': collection,
'filter[item][eq]': primaryKey,
'filter[action][in]': 'create,update,soft-delete,delete',
sort: '-id', // directus_activity has auto increment and is therefore in chronological order
fields: [
'id',
'action',
'action_on',
'action_by.id',
'action_by.first_name',
'action_by.last_name',
'action_by.avatar.data',
'comment',
],
},
}
);
const records = [];
for (const record of response.data.data) {
records.push({
...record,
date_relative: await localizedFormatDistance(
new Date(record.action_on),
new Date(),
{
addSuffix: true,
}
),
});
}
activity.value = records;
} catch (error) {
error.value = error;
} finally {
loading.value = false;
}
}
async function refresh() {
await getActivity();
}
}
function useComment() {
const newCommentContent = ref(null);
const saving = ref(false);
return { newCommentContent, postComment, saving };
async function postComment() {
saving.value = true;
try {
await api.post(`/${projectsStore.state.currentProjectKey}/activity/comment`, {
collection: props.collection,
item: props.primaryKey,
comment: newCommentContent.value,
});
await refresh();
newCommentContent.value = null;
notify({
title: i18n.t('post_comment_success'),
type: 'success',
});
} catch {
notify({
title: i18n.t('post_comment_failed'),
type: 'error',
});
} finally {
saving.value = false;
}
}
}
},
});
</script>
<style lang="scss" scoped>
.day-break {
position: sticky;
top: 0;
z-index: 2;
margin-top: 8px;
margin-bottom: 8px;
padding-top: 8px;
padding-bottom: 8px;
background-color: var(--background-normal);
box-shadow: 0 0 4px 2px var(--background-normal);
&:first-of-type {
margin-top: 0;
}
span {
z-index: 2;
padding-right: 8px;
color: var(--foreground-subdued);
background-color: var(--background-normal);
}
&::before {
position: absolute;
top: 18px;
right: 0;
left: 0;
z-index: -1;
height: 2px;
background-color: var(--border-normal);
content: '';
}
}
.revision {
position: relative;
margin-bottom: 16px;
margin-left: 20px;
&.internal {
cursor: pointer;
&::after {
position: absolute;
top: 12px;
left: -17px;
z-index: 1;
width: 2px;
height: calc(100% + 12px);
background-color: var(--background-normal-alt);
content: '';
}
}
&.external {
cursor: auto;
}
.header {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
.dot {
position: absolute;
top: 6px;
left: -22px;
width: 12px;
height: 12px;
background-color: var(--warning);
border: 2px solid var(--background-normal);
border-radius: 8px;
&.create {
background-color: var(--success);
}
&.update {
background-color: var(--primary);
}
&.delete {
background-color: var(--danger);
}
}
.more {
flex-basis: 24px;
color: var(--foreground-subdued);
transition: color var(--fast) var(--transition);
}
}
.content {
color: var(--foreground-subdued);
line-height: 16px;
.time {
text-transform: lowercase;
}
.comment {
position: relative;
margin-top: 8px;
padding: 8px;
background-color: var(--background-page);
border-radius: var(--border-radius);
&::before {
position: absolute;
top: -4px;
left: 20px;
width: 10px;
height: 10px;
background-color: var(--background-page);
border-radius: 2px;
transform: translateX(-50%) rotate(45deg);
content: '';
}
}
}
&:hover {
.header {
.more {
color: var(--foreground-normal);
}
}
}
}
.slide-enter-active,
.slide-leave-active {
transition: all var(--slow) var(--transition);
}
.slide-leave-active {
position: absolute;
}
.slide-move {
transition: all 500ms var(--transition);
}
.slide-enter,
.slide-leave-to {
opacity: 0;
}
</style>