feat: use channel portraits across app
All checks were successful
deploy-socialize / image (push) Successful in 50s
deploy-socialize / deploy (push) Successful in 20s

This commit is contained in:
2026-05-09 13:33:46 -04:00
parent afcdd1ace1
commit 01a44abc9c
3 changed files with 67 additions and 86 deletions

View File

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

View File

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

View File

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