refactor: organize frontend by feature
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-04-25 01:05:50 -04:00
parent b6eb692c27
commit 121757546a
60 changed files with 107 additions and 183 deletions

View File

@@ -0,0 +1,122 @@
import { computed } from 'vue';
import { defineStore } from 'pinia';
import { useSessionStorage } from '@vueuse/core';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
export const useChannelsStore = defineStore('channels', () => {
const workspaceStore = useWorkspaceStore();
const contentItemsStore = useContentItemsStore();
const customChannelsByWorkspace = useSessionStorage('workspace-custom-channels', {}, {
serializer: {
read: value => (value ? JSON.parse(value) : {}),
write: value => JSON.stringify(value ?? {}),
},
});
const channels = computed(() => {
const currentWorkspaceId = workspaceStore.activeWorkspaceId;
if (!currentWorkspaceId) {
return [];
}
const derivedChannels = new Map();
const customChannels = customChannelsByWorkspace.value[currentWorkspaceId] ?? [];
for (const item of contentItemsStore.items) {
for (const name of parseTargets(item.publicationTargets)) {
const key = slugify(name);
const existing = derivedChannels.get(key) ?? {
id: key,
name,
network: null,
source: 'derived',
};
derivedChannels.set(key, existing);
}
}
for (const channel of customChannels) {
derivedChannels.set(channel.id, {
...channel,
source: 'custom',
});
}
return [...derivedChannels.values()].sort((left, right) => left.name.localeCompare(right.name));
});
const availableNetworks = [
'Instagram',
'TikTok',
'Facebook',
'LinkedIn',
'YouTube',
'X',
'Reddit',
'Website',
];
function createChannel(payload) {
const currentWorkspaceId = workspaceStore.activeWorkspaceId;
if (!currentWorkspaceId) {
throw new Error('An active workspace is required to create a channel.');
}
const normalizedName = payload.name.trim();
const normalizedNetwork = payload.network.trim();
if (!normalizedName) {
throw new Error('Channel name is required.');
}
if (!normalizedNetwork) {
throw new Error('Network is required.');
}
if (!availableNetworks.includes(normalizedNetwork)) {
throw new Error('Selected network is invalid.');
}
const existing = channels.value.some(channel =>
channel.name.toLowerCase() === normalizedName.toLowerCase()
&& (channel.network ?? '').toLowerCase() === normalizedNetwork.toLowerCase()
);
if (existing) {
throw new Error('A channel with this name already exists for the selected network.');
}
const next = customChannelsByWorkspace.value[currentWorkspaceId] ?? [];
customChannelsByWorkspace.value = {
...customChannelsByWorkspace.value,
[currentWorkspaceId]: [
...next,
{
id: slugify(`${normalizedNetwork}-${normalizedName}`),
name: normalizedName,
network: normalizedNetwork,
},
],
};
}
function parseTargets(value) {
return (value ?? '')
.split(/[,\n]+/)
.map(target => target.trim())
.filter(Boolean);
}
function slugify(value) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
return {
availableNetworks,
channels,
createChannel,
};
});

View 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>