mirror of
https://github.com/directus/directus.git
synced 2026-02-19 10:14:33 -05:00
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:
@@ -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>
|
||||
@@ -1,4 +0,0 @@
|
||||
import ActivityDrawerDetail from './activity-drawer-detail.vue';
|
||||
|
||||
export { ActivityDrawerDetail };
|
||||
export default ActivityDrawerDetail;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
import CommentsDrawerDetail from './comments-drawer-detail.vue';
|
||||
|
||||
export { CommentsDrawerDetail };
|
||||
export default CommentsDrawerDetail;
|
||||
@@ -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" />
|
||||
```
|
||||
21
src/views/private/components/comments-drawer-detail/types.ts
Normal file
21
src/views/private/components/comments-drawer-detail/types.ts
Normal 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[];
|
||||
};
|
||||
@@ -95,7 +95,7 @@ body {
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 20px;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export default defineComponent({
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
z-index: 50;
|
||||
width: 280px;
|
||||
width: 260px;
|
||||
direction: rtl;
|
||||
|
||||
> *,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import RevisionsDrawerDetail from './revisions-drawer-detail.vue';
|
||||
|
||||
export { RevisionsDrawerDetail };
|
||||
export default RevisionsDrawerDetail;
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user