Add real workspace channels

This commit is contained in:
2026-05-05 13:06:57 -04:00
parent 6e658b8215
commit 244be555f9
26 changed files with 2598 additions and 128 deletions

View File

@@ -1,52 +1,19 @@
import { computed } from 'vue';
import { ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useSessionStorage } from '@vueuse/core';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
import { useClient } from '@/plugins/api.js';
export const useChannelsStore = defineStore('channels', () => {
const authStore = useAuthStore();
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 client = useClient();
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 = normalizeChannelKey(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 channels = ref([]);
const isLoading = ref(false);
const isCreating = ref(false);
const error = ref(null);
const loadedWorkspaceId = ref(null);
const availableNetworks = [
'Instagram',
@@ -59,64 +26,102 @@ export const useChannelsStore = defineStore('channels', () => {
'Website',
];
function createChannel(payload) {
async function fetchChannels({ force = false } = {}) {
const currentWorkspaceId = workspaceStore.activeWorkspaceId;
if (!currentWorkspaceId) {
if (!authStore.isAuthenticated || !currentWorkspaceId) {
channels.value = [];
error.value = null;
loadedWorkspaceId.value = null;
return;
}
if (!force && loadedWorkspaceId.value === currentWorkspaceId) {
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await client.get('/api/channels', {
params: {
workspaceId: currentWorkspaceId,
},
});
channels.value = response.data ?? [];
loadedWorkspaceId.value = currentWorkspaceId;
} catch (fetchError) {
console.error('Failed to fetch channels:', fetchError);
channels.value = [];
loadedWorkspaceId.value = null;
error.value = 'Failed to load channels.';
} finally {
isLoading.value = false;
}
}
async function createChannel(payload) {
const currentWorkspaceId = workspaceStore.activeWorkspaceId;
if (!authStore.isAuthenticated || !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 (isCreating.value) {
throw new Error('A channel creation request is already in progress.');
}
if (!normalizedNetwork) {
throw new Error('Network is required.');
}
isCreating.value = true;
error.value = null;
if (!availableNetworks.includes(normalizedNetwork)) {
throw new Error('Selected network is invalid.');
}
try {
const response = await client.post('/api/channels', {
...payload,
workspaceId: currentWorkspaceId,
});
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.');
}
if (response.data) {
channels.value = [...channels.value, response.data]
.sort((left, right) => left.name.localeCompare(right.name));
}
const next = customChannelsByWorkspace.value[currentWorkspaceId] ?? [];
customChannelsByWorkspace.value = {
...customChannelsByWorkspace.value,
[currentWorkspaceId]: [
...next,
{
id: normalizeChannelKey(`${normalizedNetwork}-${normalizedName}`),
name: normalizedName,
network: normalizedNetwork,
},
],
};
return response.data;
} catch (createError) {
console.error('Failed to create channel:', createError);
const message = createError.response?.data?.errors?.[0]?.reason
?? createError.response?.data?.message
?? 'Failed to create channel.';
error.value = message;
throw new Error(message);
} finally {
isCreating.value = false;
}
}
function parseTargets(value) {
return (value ?? '')
.split(/[,\n]+/)
.map(target => target.trim())
.filter(Boolean);
}
watch(
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
async ([isAuthenticated, workspaceId]) => {
if (!isAuthenticated || !workspaceId) {
channels.value = [];
error.value = null;
loadedWorkspaceId.value = null;
return;
}
function normalizeChannelKey(value) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
await fetchChannels();
},
{ immediate: true }
);
return {
availableNetworks,
channels,
isLoading,
isCreating,
error,
fetchChannels,
createChannel,
};
});

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
@@ -87,11 +87,11 @@
isCreateFormVisible.value = true;
}
function submitForm() {
async function submitForm() {
formError.value = null;
try {
channelsStore.createChannel({
await channelsStore.createChannel({
name: form.name,
network: form.network,
});
@@ -118,6 +118,10 @@
},
{ immediate: true }
);
onMounted(() => {
channelsStore.fetchChannels();
});
</script>
<template>
@@ -178,15 +182,30 @@
<button
class="primary"
type="button"
:disabled="channelsStore.isCreating"
@click="submitForm"
>
{{ t('channels.createTitle') }}
{{ channelsStore.isCreating ? t('common.saving') : t('channels.createTitle') }}
</button>
</div>
</div>
<div
v-if="channelsForActiveNetwork.length"
v-if="channelsStore.isLoading"
class="page-message"
>
{{ t('loading') }}
</div>
<div
v-else-if="channelsStore.error"
class="page-message error"
>
{{ channelsStore.error }}
</div>
<div
v-else-if="channelsForActiveNetwork.length"
class="channel-grid"
>
<article