feat: use channel portraits across app
This commit is contained in:
@@ -3,7 +3,6 @@
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
|
||||||
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
||||||
import {
|
import {
|
||||||
mdiClose,
|
mdiClose,
|
||||||
@@ -24,7 +23,6 @@
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const workspaceStore = useWorkspaceStore();
|
const workspaceStore = useWorkspaceStore();
|
||||||
const contentItemsStore = useContentItemsStore();
|
|
||||||
const channelsStore = useChannelsStore();
|
const channelsStore = useChannelsStore();
|
||||||
|
|
||||||
const isCreateFormVisible = ref(false);
|
const isCreateFormVisible = ref(false);
|
||||||
@@ -55,12 +53,10 @@
|
|||||||
channelsStore.channels
|
channelsStore.channels
|
||||||
.filter(channel => channel.network)
|
.filter(channel => channel.network)
|
||||||
.map(channel => {
|
.map(channel => {
|
||||||
const metrics = buildMetrics(channel.name);
|
|
||||||
const workspace = workspaceStore.workspaces.find(candidate => candidate.id === channel.workspaceId);
|
const workspace = workspaceStore.workspaces.find(candidate => candidate.id === channel.workspaceId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...channel,
|
...channel,
|
||||||
...metrics,
|
|
||||||
workspaceName: workspace?.name ?? t('nav.noWorkspace'),
|
workspaceName: workspace?.name ?? t('nav.noWorkspace'),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -80,27 +76,8 @@
|
|||||||
portraitUrl: form.portraitUrl.trim(),
|
portraitUrl: form.portraitUrl.trim(),
|
||||||
bannerUrl: form.bannerUrl.trim(),
|
bannerUrl: form.bannerUrl.trim(),
|
||||||
workspaceName: workspaceStore.activeWorkspace?.name ?? t('nav.noWorkspace'),
|
workspaceName: workspaceStore.activeWorkspace?.name ?? t('nav.noWorkspace'),
|
||||||
scheduled: 0,
|
|
||||||
readyCount: 0,
|
|
||||||
blockedCount: 0,
|
|
||||||
nextDueDate: null,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function buildMetrics(channelName) {
|
|
||||||
const matches = contentItemsStore.items.filter(item =>
|
|
||||||
parseTargets(item.publicationTargets).some(target => target.toLowerCase() === channelName.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
scheduled: matches.length,
|
|
||||||
nextDueDate: matches
|
|
||||||
.filter(item => item.dueDate)
|
|
||||||
.sort((left, right) => new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime())[0]?.dueDate ?? null,
|
|
||||||
readyCount: matches.filter(item => ['Approved', 'Scheduled', 'Published'].includes(item.status)).length,
|
|
||||||
blockedCount: matches.filter(item => item.status === 'In approval').length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
form.name = '';
|
form.name = '';
|
||||||
form.network = activeNetwork.value;
|
form.network = activeNetwork.value;
|
||||||
@@ -172,13 +149,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTargets(value) {
|
|
||||||
return (value ?? '')
|
|
||||||
.split(/[,\n]+/)
|
|
||||||
.map(target => target.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function channelHandle(channel) {
|
function channelHandle(channel) {
|
||||||
const rawHandle = channel.handle || channel.name || channel.network;
|
const rawHandle = channel.handle || channel.name || channel.network;
|
||||||
|
|
||||||
@@ -484,25 +454,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="channel-metrics">
|
<p>{{ channel.workspaceName }}</p>
|
||||||
<div>
|
|
||||||
<small>{{ t('channels.metrics.scheduled') }}</small>
|
|
||||||
<strong>{{ channel.scheduled }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<small>{{ t('channels.metrics.ready') }}</small>
|
|
||||||
<strong>{{ channel.readyCount }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<small>{{ t('channels.metrics.blocked') }}</small>
|
|
||||||
<strong>{{ channel.blockedCount }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="channel-footer">
|
|
||||||
<span>{{ t('channels.nextDue') }}</span>
|
|
||||||
<em>{{ channel.nextDueDate ? new Date(channel.nextDueDate).toLocaleDateString() : t('channels.noScheduled') }}</em>
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -531,9 +483,6 @@
|
|||||||
|
|
||||||
.header p,
|
.header p,
|
||||||
.channel-profile-row span,
|
.channel-profile-row span,
|
||||||
.channel-footer span,
|
|
||||||
.channel-footer em,
|
|
||||||
.channel-metrics small,
|
|
||||||
.page-message,
|
.page-message,
|
||||||
.empty-state span {
|
.empty-state span {
|
||||||
@apply text-sm leading-6 not-italic;
|
@apply text-sm leading-6 not-italic;
|
||||||
@@ -606,8 +555,7 @@
|
|||||||
|
|
||||||
.panel-header strong,
|
.panel-header strong,
|
||||||
.field,
|
.field,
|
||||||
.channel-profile-row strong,
|
.channel-profile-row strong {
|
||||||
.channel-metrics strong {
|
|
||||||
color: var(--app-color-on-surface);
|
color: var(--app-color-on-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,10 +597,6 @@
|
|||||||
@apply flex justify-end gap-3;
|
@apply flex justify-end gap-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-footer {
|
|
||||||
@apply flex items-center justify-between gap-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-preview-card {
|
.channel-preview-card {
|
||||||
@apply overflow-hidden p-0;
|
@apply overflow-hidden p-0;
|
||||||
}
|
}
|
||||||
@@ -709,7 +653,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.channel-preview-card > p {
|
.channel-preview-card > p {
|
||||||
@apply px-5 text-sm font-semibold;
|
@apply px-5 pb-5 text-sm font-semibold;
|
||||||
color: var(--app-text-muted);
|
color: var(--app-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,25 +689,6 @@
|
|||||||
background: linear-gradient(135deg, #0f766e, #2563eb);
|
background: linear-gradient(135deg, #0f766e, #2563eb);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-metrics {
|
|
||||||
@apply mx-5 grid grid-cols-3 gap-3 rounded-[1rem] border p-4;
|
|
||||||
background: var(--app-color-on-primary);
|
|
||||||
border-color: var(--app-border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-metrics div {
|
|
||||||
@apply flex flex-col gap-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-metrics strong {
|
|
||||||
@apply text-2xl font-black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-footer {
|
|
||||||
@apply border-t px-5 py-4;
|
|
||||||
border-color: var(--app-border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-message {
|
.page-message {
|
||||||
@apply rounded-[1.25rem] border p-4 font-medium;
|
@apply rounded-[1.25rem] border p-4 font-medium;
|
||||||
background: rgba(255, 255, 255, 0.84);
|
background: rgba(255, 255, 255, 0.84);
|
||||||
|
|||||||
@@ -965,8 +965,20 @@
|
|||||||
return placement?.channelName || placement?.network || 'Selected channel';
|
return placement?.channelName || placement?.network || 'Selected channel';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function placementChannel(placement = activePlacement.value) {
|
||||||
|
return availableChannels.value.find(candidate => candidate.id === placement?.channelId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelInitials(channel) {
|
||||||
|
const source = channel?.name || channel?.network || '';
|
||||||
|
const words = source.split(/\s+/).filter(Boolean);
|
||||||
|
const initials = words.slice(0, 2).map(word => word[0]).join('');
|
||||||
|
|
||||||
|
return initials.toUpperCase() || 'CH';
|
||||||
|
}
|
||||||
|
|
||||||
function previewHandle(placement = activePlacement.value) {
|
function previewHandle(placement = activePlacement.value) {
|
||||||
const channel = availableChannels.value.find(candidate => candidate.id === placement?.channelId);
|
const channel = placementChannel(placement);
|
||||||
const handle = channel?.handle || placement?.channelName || placement?.network || 'channel';
|
const handle = channel?.handle || placement?.channelName || placement?.network || 'channel';
|
||||||
|
|
||||||
return handle.startsWith('@') ? handle : `@${handle.replace(/\s+/g, '').toLowerCase()}`;
|
return handle.startsWith('@') ? handle : `@${handle.replace(/\s+/g, '').toLowerCase()}`;
|
||||||
@@ -1251,10 +1263,14 @@
|
|||||||
class="target-check"
|
class="target-check"
|
||||||
:icon="isChannelSelected(channel.id) ? mdiCheckboxMarked : mdiCheckboxBlankOutline"
|
:icon="isChannelSelected(channel.id) ? mdiCheckboxMarked : mdiCheckboxBlankOutline"
|
||||||
/>
|
/>
|
||||||
<v-icon
|
<span class="target-network">
|
||||||
class="target-network"
|
<img
|
||||||
:icon="channelIcon(channel.network)"
|
v-if="channel.portraitUrl"
|
||||||
|
:src="channel.portraitUrl"
|
||||||
|
:alt="channel.name"
|
||||||
/>
|
/>
|
||||||
|
<span v-else>{{ channelInitials(channel) }}</span>
|
||||||
|
</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1277,7 +1293,12 @@
|
|||||||
<div class="preview-topbar">
|
<div class="preview-topbar">
|
||||||
<div class="preview-profile">
|
<div class="preview-profile">
|
||||||
<div class="preview-avatar">
|
<div class="preview-avatar">
|
||||||
<v-icon :icon="channelIcon(activePlacement.network)" />
|
<img
|
||||||
|
v-if="placementChannel(activePlacement)?.portraitUrl"
|
||||||
|
:src="placementChannel(activePlacement).portraitUrl"
|
||||||
|
:alt="previewProfileName(activePlacement)"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ channelInitials(placementChannel(activePlacement) ?? activePlacement) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ previewProfileName(activePlacement) }}</strong>
|
<strong>{{ previewProfileName(activePlacement) }}</strong>
|
||||||
@@ -2002,11 +2023,16 @@
|
|||||||
|
|
||||||
.target-network,
|
.target-network,
|
||||||
.preview-avatar {
|
.preview-avatar {
|
||||||
@apply grid h-7 w-7 place-items-center rounded-full;
|
@apply grid h-7 w-7 place-items-center overflow-hidden rounded-full text-[0.65rem] font-black;
|
||||||
background: rgba(15, 118, 110, 0.12);
|
background: rgba(15, 118, 110, 0.12);
|
||||||
color: var(--app-color-on-tertiary);
|
color: var(--app-color-on-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.target-network img,
|
||||||
|
.preview-avatar img {
|
||||||
|
@apply h-full w-full object-cover;
|
||||||
|
}
|
||||||
|
|
||||||
.target-channel.active .target-network {
|
.target-channel.active .target-network {
|
||||||
background: rgba(255, 255, 255, 0.18);
|
background: rgba(255, 255, 255, 0.18);
|
||||||
color: var(--app-color-on-primary);
|
color: var(--app-color-on-primary);
|
||||||
|
|||||||
@@ -139,6 +139,14 @@
|
|||||||
isNotificationsOpen.value = !isNotificationsOpen.value;
|
isNotificationsOpen.value = !isNotificationsOpen.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function channelInitials(channel) {
|
||||||
|
const source = channel?.name || channel?.network || '';
|
||||||
|
const words = source.split(/\s+/).filter(Boolean);
|
||||||
|
const initials = words.slice(0, 2).map(word => word[0]).join('');
|
||||||
|
|
||||||
|
return initials.toUpperCase() || 'CH';
|
||||||
|
}
|
||||||
|
|
||||||
function updateCollapsedSearchPanelPosition() {
|
function updateCollapsedSearchPanelPosition() {
|
||||||
if (props.isExpanded || !searchRef.value) {
|
if (props.isExpanded || !searchRef.value) {
|
||||||
collapsedSearchPanelStyle.value = {};
|
collapsedSearchPanelStyle.value = {};
|
||||||
@@ -657,9 +665,17 @@
|
|||||||
v-for="channel in channelsStore.channels"
|
v-for="channel in channelsStore.channels"
|
||||||
:key="channel.id"
|
:key="channel.id"
|
||||||
:to="{ name: 'channels', query: { channel: channel.id } }"
|
:to="{ name: 'channels', query: { channel: channel.id } }"
|
||||||
class="sidebar-sublink"
|
class="sidebar-sublink sidebar-sublink-channel"
|
||||||
active-class="sidebar-sublink-active"
|
active-class="sidebar-sublink-active"
|
||||||
>
|
>
|
||||||
|
<span class="sidebar-channel-portrait">
|
||||||
|
<img
|
||||||
|
v-if="channel.portraitUrl"
|
||||||
|
:src="channel.portraitUrl"
|
||||||
|
:alt="channel.name"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ channelInitials(channel) }}</span>
|
||||||
|
</span>
|
||||||
<span>{{ channel.name }}</span>
|
<span>{{ channel.name }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
@@ -1018,6 +1034,20 @@
|
|||||||
color: var(--app-text-muted);
|
color: var(--app-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-sublink-channel {
|
||||||
|
@apply flex-row items-center gap-2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-channel-portrait {
|
||||||
|
@apply grid h-7 w-7 shrink-0 place-items-center overflow-hidden rounded-full text-[0.65rem] font-black;
|
||||||
|
background: rgba(15, 118, 110, 0.12);
|
||||||
|
color: var(--app-color-on-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-channel-portrait img {
|
||||||
|
@apply h-full w-full object-cover;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-sublink:hover,
|
.sidebar-sublink:hover,
|
||||||
.sidebar-sublink-active {
|
.sidebar-sublink-active {
|
||||||
background: var(--app-control-hover);
|
background: var(--app-control-hover);
|
||||||
|
|||||||
Reference in New Issue
Block a user