Add real workspace channels
This commit is contained in:
106
frontend/src/api/schema.d.ts
vendored
106
frontend/src/api/schema.d.ts
vendored
@@ -788,6 +788,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/channels": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["SocializeApiModulesChannelsHandlersGetChannelsHandler"];
|
||||
put?: never;
|
||||
post: operations["SocializeApiModulesChannelsHandlersCreateChannelHandler"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/campaigns": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1411,6 +1427,27 @@ export interface components {
|
||||
primaryContactEmail?: string | null;
|
||||
primaryContactPortraitUrl?: string | null;
|
||||
};
|
||||
SocializeApiModulesChannelsHandlersChannelDto: {
|
||||
/** Format: guid */
|
||||
id?: string;
|
||||
/** Format: guid */
|
||||
workspaceId?: string;
|
||||
name?: string;
|
||||
network?: string;
|
||||
handle?: string | null;
|
||||
externalUrl?: string | null;
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
};
|
||||
SocializeApiModulesChannelsHandlersCreateChannelRequest: {
|
||||
/** Format: guid */
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
network: string;
|
||||
handle?: string | null;
|
||||
externalUrl?: string | null;
|
||||
};
|
||||
SocializeApiModulesChannelsHandlersGetChannelsRequest: Record<string, never>;
|
||||
SocializeApiModulesCampaignsHandlersCampaignDto: {
|
||||
/** Format: guid */
|
||||
id?: string;
|
||||
@@ -3426,6 +3463,75 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesChannelsHandlersGetChannelsHandler: {
|
||||
parameters: {
|
||||
query?: {
|
||||
workspaceId?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Success */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesChannelsHandlersChannelDto"][];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesChannelsHandlersCreateChannelHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesChannelsHandlersCreateChannelRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Success */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesChannelsHandlersChannelDto"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesCampaignsHandlersGetCampaignsHandler: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onBeforeUnmount, reactive, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useSessionStorage } from '@vueuse/core';
|
||||
import { mdiArrowLeft } from '@mdi/js';
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
||||
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
|
||||
@@ -332,6 +333,23 @@
|
||||
commentForm.body = '';
|
||||
}
|
||||
|
||||
async function navigateBackToContent() {
|
||||
const returnTo = typeof route.query.returnTo === 'string' ? route.query.returnTo : '';
|
||||
const previousPath = router.options.history.state.back;
|
||||
|
||||
if (returnTo.startsWith('/app/content')) {
|
||||
await router.push(returnTo);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof previousPath === 'string' && previousPath.startsWith('/app/content')) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
|
||||
await router.push({ name: 'content-items' });
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
return value ? new Date(value).toLocaleString() : '';
|
||||
}
|
||||
@@ -373,6 +391,15 @@
|
||||
|
||||
<template>
|
||||
<section class="editor-shell">
|
||||
<button
|
||||
class="back-button"
|
||||
type="button"
|
||||
@click="navigateBackToContent"
|
||||
>
|
||||
<v-icon :icon="mdiArrowLeft" />
|
||||
Back to content
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="!isCreateMode && detailStore.isLoading"
|
||||
class="page-message"
|
||||
@@ -838,9 +865,22 @@
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.back-button,
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold transition;
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-bold transition;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
@apply w-fit border;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border-color: rgba(23, 32, 51, 0.12);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
|
||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const campaignsStore = useCampaignsStore();
|
||||
const contentItemsStore = useContentItemsStore();
|
||||
|
||||
const today = startOfDay(new Date());
|
||||
const viewMode = ref('month');
|
||||
const cursorDate = ref(today);
|
||||
const viewMode = ref(parseViewMode(route.query.view));
|
||||
const cursorDate = ref(parseCursorDate(route.query.date, today));
|
||||
|
||||
const contentStatusMeta = {
|
||||
Draft: { tone: 'production' },
|
||||
@@ -165,7 +168,7 @@
|
||||
dayKey: dateKey(item.dueDate),
|
||||
timeLabel: formatHour(item.dueDate),
|
||||
tone: statusMeta.tone,
|
||||
route: { name: 'content-item-detail', params: { id: item.id } },
|
||||
route: { name: 'content-item-detail', params: { id: item.id }, query: { returnTo: route.fullPath } },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,6 +278,45 @@
|
||||
function sortByDate(left, right) {
|
||||
return left.scheduledAt.getTime() - right.scheduledAt.getTime();
|
||||
}
|
||||
|
||||
function parseViewMode(value) {
|
||||
return ['month', 'week', 'upcoming'].includes(value) ? value : 'month';
|
||||
}
|
||||
|
||||
function parseCursorDate(value, fallback) {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = new Date(`${value}T00:00:00`);
|
||||
return Number.isNaN(parsed.getTime()) ? fallback : startOfDay(parsed);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
query => {
|
||||
viewMode.value = parseViewMode(query.view);
|
||||
cursorDate.value = parseCursorDate(query.date, today);
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [viewMode.value, dateKey(cursorDate.value)],
|
||||
([view, date]) => {
|
||||
if (route.query.view === view && route.query.date === date) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace({
|
||||
name: 'content-items',
|
||||
query: {
|
||||
...route.query,
|
||||
view,
|
||||
date,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -429,7 +471,7 @@
|
||||
<router-link
|
||||
v-for="item in upcomingItems"
|
||||
:key="item.id"
|
||||
:to="{ name: 'content-item-detail', params: { id: item.id } }"
|
||||
:to="{ name: 'content-item-detail', params: { id: item.id }, query: { returnTo: route.fullPath } }"
|
||||
class="item-card"
|
||||
>
|
||||
<div class="version-chip">{{ item.currentRevisionLabel }}</div>
|
||||
|
||||
@@ -443,6 +443,8 @@
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"brandStage": "Preview",
|
||||
"brandStageLabel": "Product stage: Preview",
|
||||
"brandCaption": "Approval workflow",
|
||||
"workspace": "Workspace",
|
||||
"notifications": "Notifications",
|
||||
@@ -826,7 +828,6 @@
|
||||
"selectCampaign": "Select a campaign",
|
||||
"dueDate": "Due date",
|
||||
"publicationTargets": "Publication targets",
|
||||
"publicationTargetsPlaceholder": "Instagram Reel, TikTok",
|
||||
"publicationMessage": "Publication message",
|
||||
"hashtags": "Hashtags",
|
||||
"hashtagsPlaceholder": "#launch #brand #campaign",
|
||||
|
||||
@@ -443,6 +443,8 @@
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"brandStage": "Preview",
|
||||
"brandStageLabel": "Statut du produit : Preview",
|
||||
"brandCaption": "Flux d'approbation",
|
||||
"workspace": "Espace de travail",
|
||||
"notifications": "Notifications",
|
||||
@@ -826,7 +828,6 @@
|
||||
"selectCampaign": "Sélectionner une campagne",
|
||||
"dueDate": "Date d'échéance",
|
||||
"publicationTargets": "Cibles de publication",
|
||||
"publicationTargetsPlaceholder": "Instagram Reel, TikTok",
|
||||
"publicationMessage": "Message de publication",
|
||||
"hashtags": "Hashtags",
|
||||
"hashtagsPlaceholder": "#lancement #marque #campagne",
|
||||
|
||||
Reference in New Issue
Block a user