feat: add editable channel images
All checks were successful
deploy-socialize / image (push) Successful in 1m20s
deploy-socialize / deploy (push) Successful in 20s

This commit is contained in:
2026-05-09 13:14:11 -04:00
parent 831ffde411
commit afcdd1ace1
18 changed files with 3786 additions and 16 deletions

View File

@@ -1156,6 +1156,54 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/channels/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["SocializeApiModulesChannelsHandlersUpdateChannelHandler"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/channels/{id}/portrait": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesChannelsHandlersUploadChannelPortraitHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/channels/{id}/banner": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesChannelsHandlersUploadChannelBannerHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/campaigns": {
parameters: {
query?: never;
@@ -2129,6 +2177,8 @@ export interface components {
network?: string;
handle?: string | null;
externalUrl?: string | null;
portraitUrl?: string | null;
bannerUrl?: string | null;
/** Format: date-time */
createdAt?: string;
};
@@ -2139,8 +2189,24 @@ export interface components {
network: string;
handle?: string | null;
externalUrl?: string | null;
portraitUrl?: string | null;
bannerUrl?: string | null;
};
SocializeApiModulesChannelsHandlersGetChannelsRequest: Record<string, never>;
SocializeApiModulesChannelsHandlersUpdateChannelRequest: {
/** Format: guid */
workspaceId: string;
name: string;
network: string;
handle?: string | null;
externalUrl?: string | null;
portraitUrl?: string | null;
bannerUrl?: string | null;
};
SocializeApiModulesChannelsHandlersUploadChannelImageRequest: {
/** Format: binary */
file: string;
};
SocializeApiModulesCampaignsHandlersCampaignDto: {
/** Format: guid */
id?: string;
@@ -5269,6 +5335,132 @@ export interface operations {
};
};
};
SocializeApiModulesChannelsHandlersUpdateChannelHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesChannelsHandlersUpdateChannelRequest"];
};
};
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;
};
};
};
SocializeApiModulesChannelsHandlersUploadChannelPortraitHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["SocializeApiModulesChannelsHandlersUploadChannelImageRequest"];
};
};
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;
};
};
};
SocializeApiModulesChannelsHandlersUploadChannelBannerHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["SocializeApiModulesChannelsHandlersUploadChannelImageRequest"];
};
};
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?: {

View File

@@ -12,6 +12,7 @@ export const useChannelsStore = defineStore('channels', () => {
const channels = ref([]);
const isLoading = ref(false);
const isCreating = ref(false);
const isUpdating = ref(false);
const error = ref(null);
const loadedWorkspaceId = ref(null);
const allWorkspacesKey = '__all__';
@@ -104,6 +105,71 @@ export const useChannelsStore = defineStore('channels', () => {
}
}
async function updateChannel(channelId, payload) {
if (!authStore.isAuthenticated) {
throw new Error('You must be signed in to update a channel.');
}
if (isUpdating.value) {
throw new Error('A channel update request is already in progress.');
}
isUpdating.value = true;
error.value = null;
try {
const response = await client.put(`/api/channels/${channelId}`, payload);
if (response.data) {
channels.value = channels.value
.map(channel => channel.id === response.data.id ? response.data : channel)
.sort((left, right) => left.name.localeCompare(right.name));
}
return response.data;
} catch (updateError) {
console.error('Failed to update channel:', updateError);
const message = updateError.response?.data?.errors?.[0]?.reason
?? updateError.response?.data?.message
?? 'Failed to update channel.';
error.value = message;
throw new Error(message);
} finally {
isUpdating.value = false;
}
}
async function uploadChannelImage(channelId, imageKind, file) {
if (!file) {
return null;
}
isUpdating.value = true;
error.value = null;
try {
const formData = new FormData();
formData.append('file', file, file.name || `${imageKind}.jpg`);
const response = await client.post(`/api/channels/${channelId}/${imageKind}`, formData);
if (response.data) {
channels.value = channels.value
.map(channel => channel.id === response.data.id ? response.data : channel)
.sort((left, right) => left.name.localeCompare(right.name));
}
return response.data;
} catch (uploadError) {
console.error('Failed to upload channel image:', uploadError);
const message = uploadError.response?.data?.errors?.[0]?.reason
?? uploadError.response?.data?.message
?? 'Failed to upload channel image.';
error.value = message;
throw new Error(message);
} finally {
isUpdating.value = false;
}
}
watch(
() => [authStore.isAuthenticated, workspaceStore.workspaceScopeKey],
async ([isAuthenticated]) => {
@@ -124,8 +190,11 @@ export const useChannelsStore = defineStore('channels', () => {
channels,
isLoading,
isCreating,
isUpdating,
error,
fetchChannels,
createChannel,
updateChannel,
uploadChannelImage,
};
});

View File

@@ -8,12 +8,15 @@
import {
mdiClose,
mdiFacebook,
mdiImage,
mdiInstagram,
mdiLinkedin,
mdiMusicNote,
mdiOpenInNew,
mdiPencil,
mdiPlus,
mdiReddit,
mdiContentSave,
mdiWeb,
mdiYoutube,
} from '@mdi/js';
@@ -25,6 +28,7 @@
const channelsStore = useChannelsStore();
const isCreateFormVisible = ref(false);
const editingChannelId = ref('');
const formError = ref(null);
const activeNetwork = ref('Instagram');
const form = reactive({
@@ -32,6 +36,8 @@
network: 'Instagram',
handle: '',
externalUrl: '',
portraitUrl: '',
bannerUrl: '',
});
const networkOptions = [
@@ -63,11 +69,16 @@
const channelsForActiveNetwork = computed(() =>
configuredChannels.value.filter(channel => channel.network === activeNetwork.value)
);
const editingChannel = computed(() =>
configuredChannels.value.find(channel => channel.id === editingChannelId.value) ?? null
);
const previewChannel = computed(() => ({
name: form.name.trim() || `${form.network} channel`,
network: form.network,
handle: form.handle.trim(),
externalUrl: form.externalUrl.trim(),
portraitUrl: form.portraitUrl.trim(),
bannerUrl: form.bannerUrl.trim(),
workspaceName: workspaceStore.activeWorkspace?.name ?? t('nav.noWorkspace'),
scheduled: 0,
readyCount: 0,
@@ -95,6 +106,8 @@
form.network = activeNetwork.value;
form.handle = '';
form.externalUrl = '';
form.portraitUrl = '';
form.bannerUrl = '';
formError.value = null;
}
@@ -102,6 +115,20 @@
activeNetwork.value = network;
resetForm();
form.network = network;
editingChannelId.value = '';
isCreateFormVisible.value = true;
}
function openEditForm(channel) {
activeNetwork.value = channel.network;
editingChannelId.value = channel.id;
form.name = channel.name ?? '';
form.network = channel.network ?? activeNetwork.value;
form.handle = channel.handle ?? '';
form.externalUrl = channel.externalUrl ?? '';
form.portraitUrl = channel.portraitUrl ?? '';
form.bannerUrl = channel.bannerUrl ?? '';
formError.value = null;
isCreateFormVisible.value = true;
}
@@ -117,14 +144,28 @@
formError.value = null;
try {
await channelsStore.createChannel({
const payload = {
name: form.name,
network: form.network,
handle: form.handle,
externalUrl: form.externalUrl,
});
portraitUrl: form.portraitUrl,
bannerUrl: form.bannerUrl,
};
if (editingChannelId.value) {
await channelsStore.updateChannel(editingChannelId.value, {
id: editingChannelId.value,
workspaceId: editingChannel.value?.workspaceId ?? workspaceStore.activeWorkspaceId,
...payload,
});
} else {
await channelsStore.createChannel(payload);
}
activeNetwork.value = form.network;
isCreateFormVisible.value = false;
editingChannelId.value = '';
resetForm();
} catch (error) {
formError.value = error.message ?? t('channels.errors.createFailed');
@@ -164,6 +205,27 @@
return `network-${(network ?? 'other').toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
}
async function uploadChannelImage(channel, imageKind, event) {
const [file] = Array.from(event.target.files ?? []);
event.target.value = '';
if (!file || !channel) {
return;
}
formError.value = null;
try {
const updated = await channelsStore.uploadChannelImage(channel.id, imageKind, file);
if (editingChannelId.value === channel.id && updated) {
form.portraitUrl = updated.portraitUrl ?? '';
form.bannerUrl = updated.bannerUrl ?? '';
}
} catch (error) {
formError.value = error.message ?? t('channels.errors.updateFailed');
}
}
watch(
() => route.query.create,
createValue => {
@@ -221,11 +283,25 @@
:class="networkClass(form.network)"
>
<div class="channel-banner">
<v-icon :icon="networkIcon(form.network)" />
<img
v-if="previewChannel.bannerUrl"
:src="previewChannel.bannerUrl"
:alt="`${previewChannel.name} banner`"
/>
<v-icon
v-else
class="banner-network-icon"
:icon="networkIcon(form.network)"
/>
</div>
<div class="channel-profile-row">
<div class="channel-portrait">
{{ channelInitials(previewChannel) }}
<img
v-if="previewChannel.portraitUrl"
:src="previewChannel.portraitUrl"
:alt="`${previewChannel.name} portrait`"
/>
<span v-else>{{ channelInitials(previewChannel) }}</span>
</div>
<div>
<strong>{{ previewChannel.name }}</strong>
@@ -240,7 +316,7 @@
@submit.prevent="submitForm"
>
<div class="panel-header">
<strong>{{ t('channels.createTitle') }}</strong>
<strong>{{ editingChannelId ? t('channels.editTitle') : t('channels.createTitle') }}</strong>
<span>{{ form.network }}</span>
</div>
@@ -277,6 +353,44 @@
variant="outlined"
hide-details
/>
<v-text-field
v-model="form.portraitUrl"
:label="t('channels.fields.portraitUrl')"
variant="outlined"
hide-details
/>
<v-text-field
v-model="form.bannerUrl"
:label="t('channels.fields.bannerUrl')"
variant="outlined"
hide-details
/>
</div>
<div
v-if="editingChannelId"
class="image-upload-row"
>
<label class="image-upload-button">
<v-icon :icon="mdiImage" />
<span>{{ t('channels.actions.changePortrait') }}</span>
<input
type="file"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
:disabled="channelsStore.isUpdating"
@change="uploadChannelImage(editingChannel, 'portrait', $event)"
/>
</label>
<label class="image-upload-button">
<v-icon :icon="mdiImage" />
<span>{{ t('channels.actions.changeBanner') }}</span>
<input
type="file"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
:disabled="channelsStore.isUpdating"
@change="uploadChannelImage(editingChannel, 'banner', $event)"
/>
</label>
</div>
<div class="panel-actions">
@@ -290,9 +404,12 @@
<v-btn variant="text" :ripple="false"
class="primary"
type="submit"
:disabled="channelsStore.isCreating"
:disabled="channelsStore.isCreating || channelsStore.isUpdating"
>
{{ channelsStore.isCreating ? t('common.saving') : t('channels.createTitle') }}
<v-icon :icon="editingChannelId ? mdiContentSave : mdiPlus" />
<span>
{{ channelsStore.isCreating || channelsStore.isUpdating ? t('common.saving') : (editingChannelId ? t('channels.saveChanges') : t('channels.createTitle')) }}
</span>
</v-btn>
</div>
</form>
@@ -323,7 +440,24 @@
:class="networkClass(channel.network)"
>
<div class="channel-banner">
<v-icon :icon="networkIcon(channel.network)" />
<img
v-if="channel.bannerUrl"
:src="channel.bannerUrl"
:alt="`${channel.name} banner`"
/>
<v-icon
v-else
class="banner-network-icon"
:icon="networkIcon(channel.network)"
/>
<button
class="channel-edit-button"
type="button"
:aria-label="`${t('channels.editTitle')} ${channel.name}`"
@click="openEditForm(channel)"
>
<v-icon :icon="mdiPencil" />
</button>
<a
v-if="channel.externalUrl"
:href="channel.externalUrl"
@@ -337,7 +471,12 @@
<div class="channel-profile-row">
<div class="channel-portrait">
{{ channelInitials(channel) }}
<img
v-if="channel.portraitUrl"
:src="channel.portraitUrl"
:alt="`${channel.name} portrait`"
/>
<span v-else>{{ channelInitials(channel) }}</span>
</div>
<div>
<strong>{{ channel.name }}</strong>
@@ -451,6 +590,7 @@
}
.primary {
@apply gap-2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
@@ -480,6 +620,21 @@
@apply grid gap-4;
}
.image-upload-row {
@apply flex flex-wrap gap-2;
}
.image-upload-button {
@apply inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-full border px-4 text-sm font-bold;
background: var(--app-control-hover);
border-color: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.image-upload-button input {
@apply sr-only;
}
.field {
@apply flex flex-col gap-2 text-sm font-semibold;
}
@@ -507,16 +662,29 @@
color: white;
}
.channel-banner :deep(.v-icon:first-child) {
.channel-banner img {
@apply h-full w-full object-cover;
}
.banner-network-icon {
@apply text-5xl opacity-90;
}
.channel-edit-button,
.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-banner a {
@apply top-12;
}
.channel-edit-button {
@apply border-0;
}
.channel-profile-row {
@apply -mt-9 flex items-end gap-3 px-5;
}
@@ -530,12 +698,16 @@
}
.channel-portrait {
@apply grid h-16 w-16 shrink-0 place-items-center rounded-full border-4 text-lg font-black shadow-sm;
@apply grid h-16 w-16 shrink-0 place-items-center overflow-hidden 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-portrait img {
@apply h-full w-full object-cover;
}
.channel-preview-card > p {
@apply px-5 text-sm font-semibold;
color: var(--app-text-muted);

View File

@@ -981,6 +981,8 @@
"title": "Channels",
"description": "Add channels to the workspace.",
"createTitle": "Create channel",
"editTitle": "Edit channel",
"saveChanges": "Save changes",
"empty": "No channels are available for the active workspace yet.",
"emptyAction": "Add a channel for {network}",
"nextDue": "Next due",
@@ -989,7 +991,13 @@
"name": "Channel name",
"network": "Network",
"handle": "Handle",
"externalUrl": "External URL"
"externalUrl": "External URL",
"portraitUrl": "Portrait URL",
"bannerUrl": "Banner URL"
},
"actions": {
"changePortrait": "Change portrait",
"changeBanner": "Change banner"
},
"metrics": {
"scheduled": "Scheduled",
@@ -997,7 +1005,8 @@
"blocked": "Blocked"
},
"errors": {
"createFailed": "The channel could not be created."
"createFailed": "The channel could not be created.",
"updateFailed": "The channel could not be updated."
}
},
"reviewQueue": {

View File

@@ -981,6 +981,8 @@
"title": "Canaux",
"description": "Ajoutez des canaux à l'espace.",
"createTitle": "Créer un canal",
"editTitle": "Modifier le canal",
"saveChanges": "Enregistrer",
"empty": "Aucun canal n'est disponible pour l'espace actif pour le moment.",
"emptyAction": "Ajouter un canal pour {network}",
"nextDue": "Prochaine échéance",
@@ -989,7 +991,13 @@
"name": "Nom du canal",
"network": "Réseau",
"handle": "Identifiant",
"externalUrl": "URL externe"
"externalUrl": "URL externe",
"portraitUrl": "URL du portrait",
"bannerUrl": "URL de la bannière"
},
"actions": {
"changePortrait": "Changer le portrait",
"changeBanner": "Changer la bannière"
},
"metrics": {
"scheduled": "Planifié",
@@ -997,7 +1005,8 @@
"blocked": "Bloqué"
},
"errors": {
"createFailed": "Le canal n'a pas pu être créé."
"createFailed": "Le canal n'a pas pu être créé.",
"updateFailed": "Le canal n'a pas pu être modifié."
}
},
"reviewQueue": {