feat: improve channel setup previews
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
mdiInstagram,
|
||||
mdiLinkedin,
|
||||
mdiMusicNote,
|
||||
mdiOpenInNew,
|
||||
mdiPlus,
|
||||
mdiReddit,
|
||||
mdiWeb,
|
||||
@@ -29,6 +30,8 @@
|
||||
const form = reactive({
|
||||
name: '',
|
||||
network: 'Instagram',
|
||||
handle: '',
|
||||
externalUrl: '',
|
||||
});
|
||||
|
||||
const networkOptions = [
|
||||
@@ -60,6 +63,17 @@
|
||||
const channelsForActiveNetwork = computed(() =>
|
||||
configuredChannels.value.filter(channel => channel.network === activeNetwork.value)
|
||||
);
|
||||
const previewChannel = computed(() => ({
|
||||
name: form.name.trim() || `${form.network} channel`,
|
||||
network: form.network,
|
||||
handle: form.handle.trim(),
|
||||
externalUrl: form.externalUrl.trim(),
|
||||
workspaceName: workspaceStore.activeWorkspace?.name ?? t('nav.noWorkspace'),
|
||||
scheduled: 0,
|
||||
readyCount: 0,
|
||||
blockedCount: 0,
|
||||
nextDueDate: null,
|
||||
}));
|
||||
|
||||
function buildMetrics(channelName) {
|
||||
const matches = contentItemsStore.items.filter(item =>
|
||||
@@ -79,6 +93,8 @@
|
||||
function resetForm() {
|
||||
form.name = '';
|
||||
form.network = activeNetwork.value;
|
||||
form.handle = '';
|
||||
form.externalUrl = '';
|
||||
formError.value = null;
|
||||
}
|
||||
|
||||
@@ -89,6 +105,14 @@
|
||||
isCreateFormVisible.value = true;
|
||||
}
|
||||
|
||||
function selectNetwork(network) {
|
||||
activeNetwork.value = network;
|
||||
|
||||
if (isCreateFormVisible.value) {
|
||||
form.network = network;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
formError.value = null;
|
||||
|
||||
@@ -96,7 +120,10 @@
|
||||
await channelsStore.createChannel({
|
||||
name: form.name,
|
||||
network: form.network,
|
||||
handle: form.handle,
|
||||
externalUrl: form.externalUrl,
|
||||
});
|
||||
activeNetwork.value = form.network;
|
||||
isCreateFormVisible.value = false;
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
@@ -111,6 +138,32 @@
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function channelHandle(channel) {
|
||||
const rawHandle = channel.handle || channel.name || channel.network;
|
||||
|
||||
if (channel.network === 'Website') {
|
||||
return channel.externalUrl || rawHandle;
|
||||
}
|
||||
|
||||
return rawHandle.startsWith('@') ? rawHandle : `@${rawHandle.replace(/\s+/g, '').toLowerCase()}`;
|
||||
}
|
||||
|
||||
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() || channel.network.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function networkIcon(network) {
|
||||
return networkOptions.find(option => option.value === network)?.icon ?? mdiWeb;
|
||||
}
|
||||
|
||||
function networkClass(network) {
|
||||
return `network-${(network ?? 'other').toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.create,
|
||||
createValue => {
|
||||
@@ -124,6 +177,17 @@
|
||||
onMounted(() => {
|
||||
channelsStore.fetchChannels();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [route.query.channel, channelsStore.channels.length],
|
||||
([channelId]) => {
|
||||
const selected = channelsStore.channels.find(channel => channel.id === channelId);
|
||||
if (selected?.network) {
|
||||
activeNetwork.value = selected.network;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -140,56 +204,99 @@
|
||||
type="button"
|
||||
class="network-tab"
|
||||
:class="{ active: activeNetwork === network.value }"
|
||||
@click="activeNetwork = network.value"
|
||||
:title="network.value"
|
||||
:aria-label="network.value"
|
||||
@click="selectNetwork(network.value)"
|
||||
>
|
||||
<v-icon :icon="network.icon" />
|
||||
<span>{{ network.value }}</span>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<section
|
||||
v-if="isCreateFormVisible"
|
||||
class="create-panel"
|
||||
>
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('channels.createTitle') }}</strong>
|
||||
<span>{{ form.network }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="formError"
|
||||
class="page-message error"
|
||||
<article
|
||||
class="channel-preview-card create-preview"
|
||||
:class="networkClass(form.network)"
|
||||
>
|
||||
{{ formError }}
|
||||
</div>
|
||||
<div class="channel-banner">
|
||||
<v-icon :icon="networkIcon(form.network)" />
|
||||
</div>
|
||||
<div class="channel-profile-row">
|
||||
<div class="channel-portrait">
|
||||
{{ channelInitials(previewChannel) }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ previewChannel.name }}</strong>
|
||||
<span>{{ channelHandle(previewChannel) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p>{{ previewChannel.workspaceName }}</p>
|
||||
</article>
|
||||
|
||||
<div class="form-grid">
|
||||
<v-text-field
|
||||
v-model="form.name"
|
||||
:label="t('channels.fields.name')"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
</div>
|
||||
<form
|
||||
class="create-form"
|
||||
@submit.prevent="submitForm"
|
||||
>
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('channels.createTitle') }}</strong>
|
||||
<span>{{ form.network }}</span>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<v-btn variant="text" :ripple="false"
|
||||
class="secondary"
|
||||
type="button"
|
||||
@click="isCreateFormVisible = false"
|
||||
<div
|
||||
v-if="formError"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</v-btn>
|
||||
<v-btn variant="text" :ripple="false"
|
||||
class="primary"
|
||||
type="button"
|
||||
:disabled="channelsStore.isCreating"
|
||||
@click="submitForm"
|
||||
>
|
||||
{{ channelsStore.isCreating ? t('common.saving') : t('channels.createTitle') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
{{ formError }}
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<v-select
|
||||
v-model="form.network"
|
||||
:items="networkOptions.map(option => option.value)"
|
||||
:label="t('channels.fields.network')"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="form.name"
|
||||
:label="t('channels.fields.name')"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="form.handle"
|
||||
:label="t('channels.fields.handle')"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="form.externalUrl"
|
||||
:label="t('channels.fields.externalUrl')"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<v-btn variant="text" :ripple="false"
|
||||
class="secondary"
|
||||
type="button"
|
||||
@click="isCreateFormVisible = false"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</v-btn>
|
||||
<v-btn variant="text" :ripple="false"
|
||||
class="primary"
|
||||
type="submit"
|
||||
:disabled="channelsStore.isCreating"
|
||||
>
|
||||
{{ channelsStore.isCreating ? t('common.saving') : t('channels.createTitle') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="channelsStore.isLoading"
|
||||
@@ -212,11 +319,30 @@
|
||||
<article
|
||||
v-for="channel in channelsForActiveNetwork"
|
||||
:key="channel.id"
|
||||
class="channel-card"
|
||||
class="channel-preview-card"
|
||||
:class="networkClass(channel.network)"
|
||||
>
|
||||
<div class="channel-header">
|
||||
<strong>{{ channel.name }}</strong>
|
||||
<span>{{ channel.workspaceName }}</span>
|
||||
<div class="channel-banner">
|
||||
<v-icon :icon="networkIcon(channel.network)" />
|
||||
<a
|
||||
v-if="channel.externalUrl"
|
||||
:href="channel.externalUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
:aria-label="channel.externalUrl"
|
||||
>
|
||||
<v-icon :icon="mdiOpenInNew" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="channel-profile-row">
|
||||
<div class="channel-portrait">
|
||||
{{ channelInitials(channel) }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ channel.name }}</strong>
|
||||
<span>{{ channelHandle(channel) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="channel-metrics">
|
||||
@@ -265,8 +391,7 @@
|
||||
}
|
||||
|
||||
.header p,
|
||||
.network-tab span,
|
||||
.channel-header span,
|
||||
.channel-profile-row span,
|
||||
.channel-footer span,
|
||||
.channel-footer em,
|
||||
.channel-metrics small,
|
||||
@@ -277,11 +402,11 @@
|
||||
}
|
||||
|
||||
.network-tabs {
|
||||
@apply flex flex-wrap gap-3;
|
||||
@apply flex flex-wrap gap-2;
|
||||
}
|
||||
|
||||
.network-tab {
|
||||
@apply inline-flex items-center gap-2 rounded-full border px-4 py-3 transition;
|
||||
@apply grid h-11 w-11 place-items-center rounded-full border p-0 transition;
|
||||
border-color: var(--app-border-subtle);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: var(--app-text-muted);
|
||||
@@ -289,23 +414,32 @@
|
||||
|
||||
.network-tab.active,
|
||||
.network-tab:hover {
|
||||
border-color: rgba(255, 138, 61, 0.28);
|
||||
background: rgba(255, 138, 61, 0.1);
|
||||
border-color: rgba(15, 118, 110, 0.24);
|
||||
background: rgba(15, 118, 110, 0.1);
|
||||
color: var(--app-color-on-surface);
|
||||
}
|
||||
|
||||
.channel-grid {
|
||||
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-3;
|
||||
@apply grid gap-4 sm:grid-cols-2 xl:grid-cols-4;
|
||||
}
|
||||
|
||||
.channel-card,
|
||||
.create-panel,
|
||||
.channel-preview-card,
|
||||
.empty-state {
|
||||
@apply flex flex-col gap-5 rounded-[1.5rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: var(--app-border-subtle);
|
||||
}
|
||||
|
||||
.create-panel {
|
||||
@apply grid gap-5 rounded-[1.5rem] border p-5 lg:grid-cols-[minmax(18rem,0.85fr)_minmax(0,1.15fr)];
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: var(--app-border-subtle);
|
||||
}
|
||||
|
||||
.create-form {
|
||||
@apply flex flex-col gap-5;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@apply items-center justify-center text-center;
|
||||
}
|
||||
@@ -332,7 +466,7 @@
|
||||
|
||||
.panel-header strong,
|
||||
.field,
|
||||
.channel-header strong,
|
||||
.channel-profile-row strong,
|
||||
.channel-metrics strong {
|
||||
color: var(--app-color-on-surface);
|
||||
}
|
||||
@@ -360,17 +494,87 @@
|
||||
@apply flex justify-end gap-3;
|
||||
}
|
||||
|
||||
.channel-header,
|
||||
.channel-footer {
|
||||
@apply flex items-center justify-between gap-4;
|
||||
}
|
||||
|
||||
.channel-header strong {
|
||||
@apply text-xl font-black;
|
||||
.channel-preview-card {
|
||||
@apply overflow-hidden p-0;
|
||||
}
|
||||
|
||||
.channel-banner {
|
||||
@apply relative grid h-24 place-items-center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.channel-banner :deep(.v-icon:first-child) {
|
||||
@apply text-5xl opacity-90;
|
||||
}
|
||||
|
||||
.channel-banner a {
|
||||
@apply absolute right-3 top-3 grid h-8 w-8 place-items-center rounded-full;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.channel-profile-row {
|
||||
@apply -mt-9 flex items-end gap-3 px-5;
|
||||
}
|
||||
|
||||
.channel-profile-row > div:last-child {
|
||||
@apply flex min-w-0 flex-col pb-1;
|
||||
}
|
||||
|
||||
.channel-profile-row strong {
|
||||
@apply truncate text-lg font-black;
|
||||
}
|
||||
|
||||
.channel-portrait {
|
||||
@apply grid h-16 w-16 shrink-0 place-items-center rounded-full border-4 text-lg font-black shadow-sm;
|
||||
background: var(--app-color-on-primary);
|
||||
border-color: var(--app-color-on-primary);
|
||||
color: var(--app-color-on-surface);
|
||||
}
|
||||
|
||||
.channel-preview-card > p {
|
||||
@apply px-5 text-sm font-semibold;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.network-instagram .channel-banner {
|
||||
background: linear-gradient(135deg, #c13584, #f56040 54%, #ffdc80);
|
||||
}
|
||||
|
||||
.network-tiktok .channel-banner {
|
||||
background: linear-gradient(135deg, #111827, #00f2ea 55%, #ff0050);
|
||||
}
|
||||
|
||||
.network-facebook .channel-banner {
|
||||
background: linear-gradient(135deg, #1877f2, #0f766e);
|
||||
}
|
||||
|
||||
.network-linkedin .channel-banner {
|
||||
background: linear-gradient(135deg, #0a66c2, #334155);
|
||||
}
|
||||
|
||||
.network-youtube .channel-banner {
|
||||
background: linear-gradient(135deg, #ff0000, #111827);
|
||||
}
|
||||
|
||||
.network-x .channel-banner {
|
||||
background: linear-gradient(135deg, #111827, #475569);
|
||||
}
|
||||
|
||||
.network-reddit .channel-banner {
|
||||
background: linear-gradient(135deg, #ff4500, #f59e0b);
|
||||
}
|
||||
|
||||
.network-website .channel-banner {
|
||||
background: linear-gradient(135deg, #0f766e, #2563eb);
|
||||
}
|
||||
|
||||
.channel-metrics {
|
||||
@apply grid grid-cols-3 gap-3 rounded-[1rem] border p-4;
|
||||
@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);
|
||||
}
|
||||
@@ -383,6 +587,11 @@
|
||||
@apply text-2xl font-black;
|
||||
}
|
||||
|
||||
.channel-footer {
|
||||
@apply border-t px-5 py-4;
|
||||
border-color: var(--app-border-subtle);
|
||||
}
|
||||
|
||||
.page-message {
|
||||
@apply rounded-[1.25rem] border p-4 font-medium;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
|
||||
Reference in New Issue
Block a user