refactor: organize frontend by feature
This commit is contained in:
376
frontend/src/features/channels/views/ChannelsView.vue
Normal file
376
frontend/src/features/channels/views/ChannelsView.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
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 {
|
||||
mdiClose,
|
||||
mdiFacebook,
|
||||
mdiInstagram,
|
||||
mdiLinkedin,
|
||||
mdiMusicNote,
|
||||
mdiPlus,
|
||||
mdiReddit,
|
||||
mdiWeb,
|
||||
mdiYoutube,
|
||||
} from '@mdi/js';
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const contentItemsStore = useContentItemsStore();
|
||||
const channelsStore = useChannelsStore();
|
||||
|
||||
const isCreateFormVisible = ref(false);
|
||||
const formError = ref(null);
|
||||
const activeNetwork = ref('Instagram');
|
||||
const form = reactive({
|
||||
name: '',
|
||||
network: 'Instagram',
|
||||
});
|
||||
|
||||
const networkOptions = [
|
||||
{ value: 'Instagram', icon: mdiInstagram },
|
||||
{ value: 'TikTok', icon: mdiMusicNote },
|
||||
{ value: 'Facebook', icon: mdiFacebook },
|
||||
{ value: 'LinkedIn', icon: mdiLinkedin },
|
||||
{ value: 'YouTube', icon: mdiYoutube },
|
||||
{ value: 'X', icon: mdiClose },
|
||||
{ value: 'Reddit', icon: mdiReddit },
|
||||
{ value: 'Website', icon: mdiWeb },
|
||||
];
|
||||
|
||||
const configuredChannels = computed(() =>
|
||||
channelsStore.channels
|
||||
.filter(channel => channel.network)
|
||||
.map(channel => {
|
||||
const metrics = buildMetrics(channel.name);
|
||||
|
||||
return {
|
||||
...channel,
|
||||
...metrics,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const channelsForActiveNetwork = computed(() =>
|
||||
configuredChannels.value.filter(channel => channel.network === activeNetwork.value)
|
||||
);
|
||||
|
||||
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', 'Ready to publish', 'Published'].includes(item.status)).length,
|
||||
blockedCount: matches.filter(item =>
|
||||
['In internal review', 'Ready for client review', 'In client review', 'Changes requested by client'].includes(item.status)
|
||||
).length,
|
||||
};
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.name = '';
|
||||
form.network = activeNetwork.value;
|
||||
formError.value = null;
|
||||
}
|
||||
|
||||
function openCreateForm(network = activeNetwork.value) {
|
||||
activeNetwork.value = network;
|
||||
resetForm();
|
||||
form.network = network;
|
||||
isCreateFormVisible.value = true;
|
||||
}
|
||||
|
||||
function submitForm() {
|
||||
formError.value = null;
|
||||
|
||||
try {
|
||||
channelsStore.createChannel({
|
||||
name: form.name,
|
||||
network: form.network,
|
||||
});
|
||||
isCreateFormVisible.value = false;
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
formError.value = error.message ?? t('channels.errors.createFailed');
|
||||
}
|
||||
}
|
||||
|
||||
function parseTargets(value) {
|
||||
return (value ?? '')
|
||||
.split(/[,\n]+/)
|
||||
.map(target => target.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.create,
|
||||
createValue => {
|
||||
if (createValue === 'true') {
|
||||
openCreateForm(activeNetwork.value);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="header">
|
||||
<h1>{{ t('channels.title') }}</h1>
|
||||
<p>{{ t('channels.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="network-tabs">
|
||||
<button
|
||||
v-for="network in networkOptions"
|
||||
:key="network.value"
|
||||
type="button"
|
||||
class="network-tab"
|
||||
:class="{ active: activeNetwork === network.value }"
|
||||
@click="activeNetwork = network.value"
|
||||
>
|
||||
<v-icon :icon="network.icon" />
|
||||
<span>{{ network.value }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
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"
|
||||
>
|
||||
{{ formError }}
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>{{ t('channels.fields.name') }}</span>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
type="button"
|
||||
@click="isCreateFormVisible = false"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="primary"
|
||||
type="button"
|
||||
@click="submitForm"
|
||||
>
|
||||
{{ t('channels.createTitle') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="channelsForActiveNetwork.length"
|
||||
class="channel-grid"
|
||||
>
|
||||
<article
|
||||
v-for="channel in channelsForActiveNetwork"
|
||||
:key="channel.id"
|
||||
class="channel-card"
|
||||
>
|
||||
<div class="channel-header">
|
||||
<strong>{{ channel.name }}</strong>
|
||||
<span>{{ workspaceStore.activeWorkspace?.name || t('nav.noWorkspace') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="channel-metrics">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="empty-state"
|
||||
@click="openCreateForm(activeNetwork)"
|
||||
>
|
||||
<v-icon :icon="mdiPlus" />
|
||||
<span>{{ t('channels.emptyAction', { network: activeNetwork }) }}</span>
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
@apply text-4xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.header p,
|
||||
.network-tab span,
|
||||
.channel-header span,
|
||||
.channel-footer span,
|
||||
.channel-footer em,
|
||||
.channel-metrics small,
|
||||
.page-message,
|
||||
.empty-state span {
|
||||
@apply text-sm leading-6 not-italic;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.network-tabs {
|
||||
@apply flex flex-wrap gap-3;
|
||||
}
|
||||
|
||||
.network-tab {
|
||||
@apply inline-flex items-center gap-2 rounded-full border px-4 py-3 transition;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.network-tab.active,
|
||||
.network-tab:hover {
|
||||
border-color: rgba(255, 138, 61, 0.28);
|
||||
background: rgba(255, 138, 61, 0.1);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.channel-grid {
|
||||
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-3;
|
||||
}
|
||||
|
||||
.channel-card,
|
||||
.create-panel,
|
||||
.empty-state {
|
||||
@apply flex flex-col gap-5 rounded-[1.5rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@apply items-center justify-center text-center;
|
||||
}
|
||||
|
||||
.create-button,
|
||||
.primary,
|
||||
.secondary {
|
||||
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold transition;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply flex items-center justify-between gap-4;
|
||||
}
|
||||
|
||||
.panel-header strong,
|
||||
.field,
|
||||
.channel-header strong,
|
||||
.channel-metrics strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.panel-header span {
|
||||
@apply text-sm font-semibold;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
@apply grid gap-4;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply flex flex-col gap-2 text-sm font-semibold;
|
||||
}
|
||||
|
||||
.field input {
|
||||
@apply rounded-2xl border px-4 py-3 text-sm;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
@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-metrics {
|
||||
@apply grid grid-cols-3 gap-3 rounded-[1rem] border p-4;
|
||||
background: #fffaf2;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.channel-metrics div {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.channel-metrics strong {
|
||||
@apply text-2xl font-black;
|
||||
}
|
||||
|
||||
.page-message {
|
||||
@apply rounded-[1.25rem] border p-4 font-medium;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.page-message.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user