702 lines
22 KiB
Vue
702 lines
22 KiB
Vue
<script setup>
|
|
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';
|
|
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
|
import {
|
|
mdiClose,
|
|
mdiFacebook,
|
|
mdiImage,
|
|
mdiInstagram,
|
|
mdiLinkedin,
|
|
mdiMusicNote,
|
|
mdiOpenInNew,
|
|
mdiPencil,
|
|
mdiPlus,
|
|
mdiReddit,
|
|
mdiContentSave,
|
|
mdiWeb,
|
|
mdiYoutube,
|
|
} from '@mdi/js';
|
|
|
|
const route = useRoute();
|
|
const { t } = useI18n();
|
|
const workspaceStore = useWorkspaceStore();
|
|
const channelsStore = useChannelsStore();
|
|
|
|
const isCreateFormVisible = ref(false);
|
|
const editingChannelId = ref('');
|
|
const formError = ref(null);
|
|
const activeNetwork = ref('Instagram');
|
|
const form = reactive({
|
|
name: '',
|
|
network: 'Instagram',
|
|
handle: '',
|
|
externalUrl: '',
|
|
portraitUrl: '',
|
|
bannerUrl: '',
|
|
});
|
|
|
|
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 workspace = workspaceStore.workspaces.find(candidate => candidate.id === channel.workspaceId);
|
|
|
|
return {
|
|
...channel,
|
|
workspaceName: workspace?.name ?? t('nav.noWorkspace'),
|
|
};
|
|
})
|
|
);
|
|
|
|
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'),
|
|
}));
|
|
|
|
function resetForm() {
|
|
form.name = '';
|
|
form.network = activeNetwork.value;
|
|
form.handle = '';
|
|
form.externalUrl = '';
|
|
form.portraitUrl = '';
|
|
form.bannerUrl = '';
|
|
formError.value = null;
|
|
}
|
|
|
|
function openCreateForm(network = activeNetwork.value) {
|
|
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;
|
|
}
|
|
|
|
function selectNetwork(network) {
|
|
activeNetwork.value = network;
|
|
|
|
if (isCreateFormVisible.value) {
|
|
form.network = network;
|
|
}
|
|
}
|
|
|
|
async function submitForm() {
|
|
formError.value = null;
|
|
|
|
try {
|
|
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');
|
|
}
|
|
}
|
|
|
|
function channelHandle(channel) {
|
|
const rawHandle = channel.handle || channel.name || channel.network;
|
|
|
|
if (channel.network === 'Website') {
|
|
return channel.externalUrl || rawHandle;
|
|
}
|
|
|
|
return rawHandle.startsWith('@') ? rawHandle : `@${rawHandle.replace(/\s+/g, '').toLowerCase()}`;
|
|
}
|
|
|
|
function channelInitials(channel) {
|
|
const source = channel.name || channel.network || '';
|
|
const words = source.split(/\s+/).filter(Boolean);
|
|
const initials = words.slice(0, 2).map(word => word[0]).join('');
|
|
|
|
return initials.toUpperCase() || channel.network.slice(0, 2).toUpperCase();
|
|
}
|
|
|
|
function networkIcon(network) {
|
|
return networkOptions.find(option => option.value === network)?.icon ?? mdiWeb;
|
|
}
|
|
|
|
function networkClass(network) {
|
|
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 => {
|
|
if (createValue === 'true') {
|
|
openCreateForm(activeNetwork.value);
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
onMounted(() => {
|
|
channelsStore.fetchChannels();
|
|
});
|
|
|
|
watch(
|
|
() => [route.query.channel, channelsStore.channels.length],
|
|
([channelId]) => {
|
|
const selected = channelsStore.channels.find(channel => channel.id === channelId);
|
|
if (selected?.network) {
|
|
activeNetwork.value = selected.network;
|
|
}
|
|
},
|
|
{ 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">
|
|
<v-btn variant="text" :ripple="false"
|
|
v-for="network in networkOptions"
|
|
:key="network.value"
|
|
type="button"
|
|
class="network-tab"
|
|
:class="{ active: activeNetwork === network.value }"
|
|
:title="network.value"
|
|
:aria-label="network.value"
|
|
@click="selectNetwork(network.value)"
|
|
>
|
|
<v-icon :icon="network.icon" />
|
|
</v-btn>
|
|
</div>
|
|
|
|
<section
|
|
v-if="isCreateFormVisible"
|
|
class="create-panel"
|
|
>
|
|
<article
|
|
class="channel-preview-card create-preview"
|
|
:class="networkClass(form.network)"
|
|
>
|
|
<div class="channel-banner">
|
|
<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">
|
|
<img
|
|
v-if="previewChannel.portraitUrl"
|
|
:src="previewChannel.portraitUrl"
|
|
:alt="`${previewChannel.name} portrait`"
|
|
/>
|
|
<span v-else>{{ channelInitials(previewChannel) }}</span>
|
|
</div>
|
|
<div>
|
|
<strong>{{ previewChannel.name }}</strong>
|
|
<span>{{ channelHandle(previewChannel) }}</span>
|
|
</div>
|
|
</div>
|
|
<p>{{ previewChannel.workspaceName }}</p>
|
|
</article>
|
|
|
|
<form
|
|
class="create-form"
|
|
@submit.prevent="submitForm"
|
|
>
|
|
<div class="panel-header">
|
|
<strong>{{ editingChannelId ? t('channels.editTitle') : t('channels.createTitle') }}</strong>
|
|
<span>{{ form.network }}</span>
|
|
</div>
|
|
|
|
<div
|
|
v-if="formError"
|
|
class="page-message error"
|
|
>
|
|
{{ formError }}
|
|
</div>
|
|
|
|
<div class="form-grid">
|
|
<v-select
|
|
v-model="form.network"
|
|
:items="networkOptions.map(option => option.value)"
|
|
:label="t('channels.fields.network')"
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
<v-text-field
|
|
v-model="form.name"
|
|
:label="t('channels.fields.name')"
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
<v-text-field
|
|
v-model="form.handle"
|
|
:label="t('channels.fields.handle')"
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
<v-text-field
|
|
v-model="form.externalUrl"
|
|
:label="t('channels.fields.externalUrl')"
|
|
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">
|
|
<v-btn variant="text" :ripple="false"
|
|
class="secondary"
|
|
type="button"
|
|
@click="isCreateFormVisible = false"
|
|
>
|
|
{{ t('common.cancel') }}
|
|
</v-btn>
|
|
<v-btn variant="text" :ripple="false"
|
|
class="primary"
|
|
type="submit"
|
|
:disabled="channelsStore.isCreating || channelsStore.isUpdating"
|
|
>
|
|
<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>
|
|
</section>
|
|
|
|
<div
|
|
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
|
|
v-for="channel in channelsForActiveNetwork"
|
|
:key="channel.id"
|
|
class="channel-preview-card"
|
|
:class="networkClass(channel.network)"
|
|
>
|
|
<div class="channel-banner">
|
|
<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"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
:aria-label="channel.externalUrl"
|
|
>
|
|
<v-icon :icon="mdiOpenInNew" />
|
|
</a>
|
|
</div>
|
|
|
|
<div class="channel-profile-row">
|
|
<div class="channel-portrait">
|
|
<img
|
|
v-if="channel.portraitUrl"
|
|
:src="channel.portraitUrl"
|
|
:alt="`${channel.name} portrait`"
|
|
/>
|
|
<span v-else>{{ channelInitials(channel) }}</span>
|
|
</div>
|
|
<div>
|
|
<strong>{{ channel.name }}</strong>
|
|
<span>{{ channelHandle(channel) }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<p>{{ channel.workspaceName }}</p>
|
|
</article>
|
|
</div>
|
|
|
|
<v-btn variant="text" :ripple="false"
|
|
v-else
|
|
type="button"
|
|
class="empty-state"
|
|
@click="openCreateForm(activeNetwork)"
|
|
>
|
|
<v-icon :icon="mdiPlus" />
|
|
<span>{{ t('channels.emptyAction', { network: activeNetwork }) }}</span>
|
|
</v-btn>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
@reference "@/assets/main.css";
|
|
.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: var(--app-color-on-surface);
|
|
}
|
|
|
|
.header p,
|
|
.channel-profile-row span,
|
|
.page-message,
|
|
.empty-state span {
|
|
@apply text-sm leading-6 not-italic;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.network-tabs {
|
|
@apply flex flex-wrap gap-2;
|
|
}
|
|
|
|
.network-tab {
|
|
@apply grid h-11 w-11 place-items-center rounded-full border p-0 transition;
|
|
border-color: var(--app-border-subtle);
|
|
background: rgba(255, 255, 255, 0.95);
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.network-tab.active,
|
|
.network-tab:hover {
|
|
border-color: rgba(15, 118, 110, 0.24);
|
|
background: rgba(15, 118, 110, 0.1);
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.channel-grid {
|
|
@apply grid gap-4 sm:grid-cols-2 xl:grid-cols-4;
|
|
}
|
|
|
|
.channel-preview-card,
|
|
.empty-state {
|
|
@apply flex flex-col gap-5 rounded-[1.5rem] border p-5;
|
|
background: rgba(255, 255, 255, 0.92);
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.create-panel {
|
|
@apply grid gap-5 rounded-[1.5rem] border p-5 lg:grid-cols-[minmax(18rem,0.85fr)_minmax(0,1.15fr)];
|
|
background: rgba(255, 255, 255, 0.92);
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.create-form {
|
|
@apply flex flex-col gap-5;
|
|
}
|
|
|
|
.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 {
|
|
@apply gap-2;
|
|
background: var(--app-color-on-surface);
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.secondary {
|
|
background: var(--app-control-hover);
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.panel-header {
|
|
@apply flex items-center justify-between gap-4;
|
|
}
|
|
|
|
.panel-header strong,
|
|
.field,
|
|
.channel-profile-row strong {
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.panel-header span {
|
|
@apply text-sm font-semibold;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.form-grid {
|
|
@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;
|
|
}
|
|
|
|
.field input {
|
|
@apply rounded-2xl border px-4 py-3 text-sm;
|
|
border-color: var(--app-border-subtle);
|
|
background: rgba(255, 255, 255, 0.95);
|
|
}
|
|
|
|
.panel-actions {
|
|
@apply flex justify-end gap-3;
|
|
}
|
|
|
|
.channel-preview-card {
|
|
@apply overflow-hidden p-0;
|
|
}
|
|
|
|
.channel-banner {
|
|
@apply relative grid h-24 place-items-center;
|
|
color: white;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.channel-profile-row > div:last-child {
|
|
@apply flex min-w-0 flex-col pb-1;
|
|
}
|
|
|
|
.channel-profile-row strong {
|
|
@apply truncate text-lg font-black;
|
|
}
|
|
|
|
.channel-portrait {
|
|
@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 pb-5 text-sm font-semibold;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.network-instagram .channel-banner {
|
|
background: linear-gradient(135deg, #c13584, #f56040 54%, #ffdc80);
|
|
}
|
|
|
|
.network-tiktok .channel-banner {
|
|
background: linear-gradient(135deg, #111827, #00f2ea 55%, #ff0050);
|
|
}
|
|
|
|
.network-facebook .channel-banner {
|
|
background: linear-gradient(135deg, #1877f2, #0f766e);
|
|
}
|
|
|
|
.network-linkedin .channel-banner {
|
|
background: linear-gradient(135deg, #0a66c2, #334155);
|
|
}
|
|
|
|
.network-youtube .channel-banner {
|
|
background: linear-gradient(135deg, #ff0000, #111827);
|
|
}
|
|
|
|
.network-x .channel-banner {
|
|
background: linear-gradient(135deg, #111827, #475569);
|
|
}
|
|
|
|
.network-reddit .channel-banner {
|
|
background: linear-gradient(135deg, #ff4500, #f59e0b);
|
|
}
|
|
|
|
.network-website .channel-banner {
|
|
background: linear-gradient(135deg, #0f766e, #2563eb);
|
|
}
|
|
|
|
.page-message {
|
|
@apply rounded-[1.25rem] border p-4 font-medium;
|
|
background: rgba(255, 255, 255, 0.84);
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.page-message.error {
|
|
color: var(--app-danger-muted);
|
|
}
|
|
</style>
|