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

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