feat: just getting better and better
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-05-04 21:34:38 -04:00
parent 664eb07201
commit b7379cf823
45 changed files with 1411 additions and 11114 deletions

View File

@@ -85,7 +85,7 @@
}
.sidebar-boundary-toggle {
@apply absolute left-full top-8 z-40 flex h-10 w-10 -translate-x-1/2 items-center justify-center rounded-full border transition-colors;
@apply absolute left-full top-[1.48rem] z-40 flex h-[1.8rem] w-[1.8rem] -translate-x-1/2 items-center justify-center rounded-full border transition-colors;
background: rgba(255, 250, 242, 0.98);
border-color: rgba(23, 32, 51, 0.12);
color: #44516a;
@@ -97,6 +97,10 @@
color: #fffaf2;
}
.sidebar-boundary-toggle :deep(.v-icon) {
font-size: 1rem;
}
.shell-view {
@apply flex min-w-0 flex-1;
}

View File

@@ -913,7 +913,6 @@ export interface components {
/** Format: guid */
organizationId?: string;
name?: string;
slug?: string;
logoUrl?: string | null;
timeZone?: string;
approvalMode?: string;
@@ -943,7 +942,6 @@ export interface components {
/** Format: guid */
organizationId: string;
name: string;
slug: string;
timeZone: string;
};
SocializeApiModulesWorkspacesHandlersWorkspaceInviteDto: {

View File

@@ -3,6 +3,7 @@ import { createMemoryHistory, createRouter, RouterView } from 'vue-router';
import { createHead, renderHeadToString } from '@vueuse/head';
import { renderToString } from '@vue/server-renderer';
import { createI18n } from 'vue-i18n';
import { createPinia } from 'pinia';
import en from '@/locales/en.json';
import fr from '@/locales/fr.json';
import Landing from '@/features/landing/views/Landing.vue';
@@ -42,6 +43,7 @@ export async function render(routePath) {
render: () => h(RouterView),
});
app.use(createPinia());
app.use(router);
app.use(head);
app.use(i18n);

View File

@@ -145,14 +145,20 @@
const props = defineProps({
returnUrl: {
type: String,
default: '/landing',
default: '/app/dashboard',
},
});
function getPostLoginUrl() {
return props.returnUrl?.startsWith('/app')
? props.returnUrl
: '/app/dashboard';
}
async function handleLocalLogin() {
try {
await authStore.login(email.value, password.value);
await router.push(props.returnUrl);
await router.push(getPostLoginUrl());
} catch (error) {
console.error('Login failed:', error);
errorSnackBar.value = true;
@@ -163,7 +169,7 @@
try {
const response = await authStore.loginWithGoogle(JSON.stringify(token));
if (response === true) {
await router.push(props.returnUrl);
await router.push(getPostLoginUrl());
} else {
errorSnackBar.value = true;
}
@@ -177,7 +183,7 @@
try {
const response = await loginWithFacebook();
if (response === true) {
await router.push(props.returnUrl);
await router.push(getPostLoginUrl());
} else {
errorSnackBar.value = true;
}

View File

@@ -2,13 +2,11 @@
import { computed, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
const route = useRoute();
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const clientsStore = useClientsStore();
const campaignsStore = useCampaignsStore();
@@ -114,16 +112,6 @@
</div>
</div>
<div class="action-row">
<button
v-if="authStore.isManager"
class="create-button"
@click="openCreateForm"
>
{{ t('campaigns.newCampaign') }}
</button>
</div>
<div
v-if="isCreateFormVisible"
class="create-panel"
@@ -274,17 +262,11 @@
color: #526178;
}
.action-row {
@apply flex justify-end;
}
.create-button,
.primary,
.secondary {
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold transition;
}
.create-button,
.primary {
background: #172033;
color: #fffaf2;

View File

@@ -26,7 +26,7 @@ export const useChannelsStore = defineStore('channels', () => {
for (const item of contentItemsStore.items) {
for (const name of parseTargets(item.publicationTargets)) {
const key = slugify(name);
const key = normalizeChannelKey(name);
const existing = derivedChannels.get(key) ?? {
id: key,
name,
@@ -95,7 +95,7 @@ export const useChannelsStore = defineStore('channels', () => {
[currentWorkspaceId]: [
...next,
{
id: slugify(`${normalizedNetwork}-${normalizedName}`),
id: normalizeChannelKey(`${normalizedNetwork}-${normalizedName}`),
name: normalizedName,
network: normalizedNetwork,
},
@@ -110,7 +110,7 @@ export const useChannelsStore = defineStore('channels', () => {
.filter(Boolean);
}
function slugify(value) {
function normalizeChannelKey(value) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}

View File

@@ -1,11 +1,280 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
const { t } = useI18n();
const authStore = useAuthStore();
const { t, locale } = useI18n();
const campaignsStore = useCampaignsStore();
const contentItemsStore = useContentItemsStore();
const today = startOfDay(new Date());
const viewMode = ref('month');
const cursorDate = ref(today);
const contentStatusMeta = {
Draft: { tone: 'production' },
'In production': { tone: 'production' },
'In approval': { tone: 'approval' },
Approved: { tone: 'ready' },
Scheduled: { tone: 'ready' },
Published: { tone: 'published' },
};
const contentItemsByCampaignId = computed(() => {
const grouped = new Map();
for (const item of contentItemsStore.items) {
const existing = grouped.get(item.campaignId) ?? [];
existing.push(item);
grouped.set(item.campaignId, existing);
}
return grouped;
});
const calendarEntries = computed(() => {
const campaignEntries = campaignsStore.campaigns
.filter(campaign => campaign.endDate || campaign.startDate)
.map(campaign => buildCampaignEntry(campaign));
const contentEntries = contentItemsStore.items
.filter(item => item.dueDate)
.map(item => buildContentEntry(item));
return [...campaignEntries, ...contentEntries].sort(sortByDate);
});
const entriesByDay = computed(() => {
const grouped = new Map();
for (const entry of calendarEntries.value) {
const existing = grouped.get(entry.dayKey) ?? [];
existing.push(entry);
grouped.set(entry.dayKey, existing);
}
return grouped;
});
const visibleDays = computed(() => {
if (viewMode.value === 'week') {
const start = startOfWeek(cursorDate.value);
return Array.from({ length: 7 }, (_, index) => buildDay(addDays(start, index), false));
}
const start = startOfWeek(startOfMonth(cursorDate.value));
const end = endOfWeek(endOfMonth(cursorDate.value));
const days = [];
let current = start;
while (current <= end) {
days.push(buildDay(current, current.getMonth() !== cursorDate.value.getMonth()));
current = addDays(current, 1);
}
return days;
});
const weekdayLabels = computed(() => {
const base = startOfWeek(cursorDate.value);
return Array.from({ length: 7 }, (_, index) =>
new Intl.DateTimeFormat(locale.value, { weekday: 'short' }).format(addDays(base, index))
);
});
const periodLabel = computed(() => {
if (viewMode.value === 'week') {
const start = startOfWeek(cursorDate.value);
const end = addDays(start, 6);
const sameMonth = start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear();
if (sameMonth) {
return new Intl.DateTimeFormat(locale.value, {
month: 'long',
day: 'numeric',
year: 'numeric',
}).formatRange(start, end);
}
return new Intl.DateTimeFormat(locale.value, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).formatRange(start, end);
}
return new Intl.DateTimeFormat(locale.value, {
month: 'long',
year: 'numeric',
}).format(cursorDate.value);
});
const upcomingItems = computed(() =>
[...contentItemsStore.items].sort((left, right) => {
if (!left.dueDate && !right.dueDate) {
return left.title.localeCompare(right.title);
}
if (!left.dueDate) {
return 1;
}
if (!right.dueDate) {
return -1;
}
return new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime();
})
);
const isLoading = computed(() =>
contentItemsStore.isLoading || campaignsStore.isLoading
);
const pageError = computed(() =>
contentItemsStore.error || campaignsStore.error
);
const isCalendarView = computed(() => viewMode.value === 'month' || viewMode.value === 'week');
function buildDay(date, isOutsideMonth) {
const key = dateKey(date);
return {
key,
date,
entries: entriesByDay.value.get(key) ?? [],
isOutsideMonth,
isToday: key === dateKey(today),
};
}
function buildContentEntry(item) {
const statusMeta = contentStatusMeta[item.status] ?? { tone: 'production' };
const campaign = campaignsStore.campaigns.find(candidate => candidate.id === item.campaignId);
return {
id: item.id,
type: 'content',
title: item.title,
subtitle: campaign?.name ?? t('dashboard.labels.unassignedCampaign'),
scheduledAt: new Date(item.dueDate),
dayKey: dateKey(item.dueDate),
timeLabel: formatHour(item.dueDate),
tone: statusMeta.tone,
route: { name: 'content-item-detail', params: { id: item.id } },
};
}
function buildCampaignEntry(campaign) {
const campaignItems = contentItemsByCampaignId.value.get(campaign.id) ?? [];
const approvedCount = campaignItems.filter(item => ['Approved', 'Scheduled', 'Published'].includes(item.status)).length;
return {
id: campaign.id,
type: 'campaign',
title: campaign.name,
subtitle: campaignItems.length
? t('dashboard.campaignProgress', { scheduled: campaignItems.length, approved: approvedCount })
: t('dashboard.readiness.missing'),
scheduledAt: new Date(campaign.endDate ?? campaign.startDate),
dayKey: dateKey(campaign.endDate ?? campaign.startDate),
timeLabel: t('dashboard.campaignDeadline'),
tone: campaignItems.length ? 'campaign' : 'risk',
route: { name: 'campaign-detail', params: { campaignId: campaign.id } },
};
}
function setView(mode) {
viewMode.value = mode;
if (mode === 'month') {
cursorDate.value = startOfMonth(cursorDate.value);
}
if (mode === 'week') {
cursorDate.value = startOfWeek(cursorDate.value);
}
}
function shiftPeriod(direction) {
cursorDate.value = viewMode.value === 'month'
? addMonths(cursorDate.value, direction)
: addDays(cursorDate.value, direction * 7);
}
function jumpToToday() {
cursorDate.value = viewMode.value === 'month' ? startOfMonth(today) : startOfWeek(today);
}
function formatDayNumber(date) {
return new Intl.DateTimeFormat(locale.value, { day: 'numeric' }).format(date);
}
function formatHour(value) {
return new Intl.DateTimeFormat(locale.value, {
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value));
}
function formatDueDate(value) {
return value ? new Date(value).toLocaleDateString() : t('contentItems.noDueDate');
}
function startOfDay(value) {
const date = new Date(value);
date.setHours(0, 0, 0, 0);
return date;
}
function startOfWeek(value) {
const date = startOfDay(value);
const day = date.getDay();
const diff = day === 0 ? -6 : 1 - day;
return addDays(date, diff);
}
function endOfWeek(value) {
return addDays(startOfWeek(value), 6);
}
function startOfMonth(value) {
const date = startOfDay(value);
date.setDate(1);
return date;
}
function endOfMonth(value) {
const date = startOfMonth(value);
date.setMonth(date.getMonth() + 1);
date.setDate(0);
return date;
}
function addDays(value, amount) {
const date = startOfDay(value);
date.setDate(date.getDate() + amount);
return date;
}
function addMonths(value, amount) {
const date = startOfMonth(value);
date.setMonth(date.getMonth() + amount);
return date;
}
function dateKey(value) {
const date = new Date(value);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function sortByDate(left, right) {
return left.scheduledAt.getTime() - right.scheduledAt.getTime();
}
</script>
<template>
@@ -17,35 +286,150 @@
<p>{{ t('contentItems.description') }}</p>
</div>
<router-link
v-if="authStore.isManager || authStore.isProvider"
:to="{ name: 'content-item-create' }"
class="create-button"
>
{{ t('contentItems.newItem') }}
</router-link>
<div class="view-toggle">
<button
class="toggle-button"
:class="{ 'toggle-button-active': viewMode === 'month' }"
type="button"
@click="setView('month')"
>
{{ t('dashboard.month') }}
</button>
<button
class="toggle-button"
:class="{ 'toggle-button-active': viewMode === 'week' }"
type="button"
@click="setView('week')"
>
{{ t('dashboard.week') }}
</button>
<button
class="toggle-button"
:class="{ 'toggle-button-active': viewMode === 'upcoming' }"
type="button"
@click="setView('upcoming')"
>
{{ t('contentItems.upcoming') }}
</button>
</div>
</div>
<div
v-if="contentItemsStore.isLoading"
v-if="isLoading"
class="page-message"
>
{{ t('contentItems.loading') }}
</div>
<div
v-else-if="contentItemsStore.error"
v-else-if="pageError"
class="page-message error"
>
{{ contentItemsStore.error }}
{{ pageError }}
</div>
<article
v-else-if="isCalendarView"
class="calendar-card"
>
<div class="calendar-toolbar">
<div class="calendar-nav">
<button
class="icon-button"
type="button"
@click="shiftPeriod(-1)"
>
<v-icon :icon="mdiChevronLeft" />
</button>
<div class="calendar-period">{{ periodLabel }}</div>
<button
class="icon-button"
type="button"
@click="shiftPeriod(1)"
>
<v-icon :icon="mdiChevronRight" />
</button>
</div>
<button
class="text-button"
type="button"
@click="jumpToToday"
>
{{ t('dashboard.today') }}
</button>
</div>
<div class="calendar-grid calendar-grid-head">
<div
v-for="label in weekdayLabels"
:key="label"
class="weekday-label"
>
{{ label }}
</div>
</div>
<div
class="calendar-grid"
:class="viewMode === 'week' ? 'calendar-grid-week' : 'calendar-grid-month'"
>
<div
v-for="day in visibleDays"
:key="day.key"
class="calendar-day"
:class="{
'calendar-day-outside': day.isOutsideMonth,
'calendar-day-today': day.isToday,
'calendar-day-week': viewMode === 'week',
}"
>
<div class="day-number">
{{ formatDayNumber(day.date) }}
</div>
<div
v-if="day.entries.length"
class="day-entries"
>
<router-link
v-for="entry in viewMode === 'month' ? day.entries.slice(0, 3) : day.entries"
:key="`${entry.type}-${entry.id}`"
:to="entry.route"
class="calendar-entry"
:class="entry.tone"
>
<span class="entry-time">{{ entry.timeLabel }}</span>
<strong>{{ entry.title }}</strong>
<span>{{ entry.subtitle }}</span>
</router-link>
<div
v-if="viewMode === 'month' && day.entries.length > 3"
class="entry-more"
>
{{ t('dashboard.moreItems', { count: day.entries.length - 3 }) }}
</div>
</div>
<div
v-else-if="viewMode === 'week'"
class="day-empty"
>
{{ t('dashboard.emptyPeriod') }}
</div>
</div>
</div>
</article>
<div
v-else-if="contentItemsStore.items.length"
v-else-if="upcomingItems.length"
class="item-grid"
>
<router-link
v-for="item in contentItemsStore.items"
v-for="item in upcomingItems"
:key="item.id"
:to="{ name: 'content-item-detail', params: { id: item.id } }"
class="item-card"
@@ -55,7 +439,7 @@
<span>{{ item.publicationTargets }}</span>
<div class="status-row">
<em>{{ item.status }}</em>
<small>{{ item.dueDate ? new Date(item.dueDate).toLocaleDateString() : t('contentItems.noDueDate') }}</small>
<small>{{ formatDueDate(item.dueDate) }}</small>
</div>
</router-link>
</div>
@@ -96,10 +480,38 @@
color: #526178;
}
.create-button {
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold no-underline transition;
.view-toggle {
@apply inline-flex w-fit rounded-full border p-1;
background: #f8fafc;
border-color: rgba(23, 32, 51, 0.1);
}
.toggle-button,
.icon-button,
.text-button {
@apply inline-flex items-center justify-center rounded-full border px-3 py-2 text-sm font-semibold transition;
background: #f8fafc;
border-color: rgba(23, 32, 51, 0.1);
color: #172033;
}
.toggle-button {
@apply border-0 bg-transparent;
}
.toggle-button-active {
background: #172033;
color: #fffaf2;
color: #ffffff;
}
.icon-button {
@apply h-10 w-10 px-0 py-0;
}
.icon-button:hover,
.text-button:hover,
.toggle-button:hover {
background: #eef4ff;
}
.page-message,
@@ -118,20 +530,138 @@
color: #b91c1c;
}
.calendar-card {
@apply rounded-[1.75rem] border p-4 md:p-5;
background: rgba(255, 255, 255, 0.94);
border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
}
.calendar-toolbar {
@apply mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between;
}
.calendar-nav {
@apply flex items-center gap-2;
}
.calendar-period {
@apply min-w-0 px-2 text-base font-bold md:text-lg;
color: #172033;
}
.calendar-grid {
@apply grid gap-3;
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.calendar-grid-head {
@apply mb-3;
}
.weekday-label {
@apply px-2 text-xs font-bold uppercase tracking-[0.16em];
color: #526178;
}
.calendar-day {
@apply min-h-[8.5rem] rounded-[1.25rem] border p-3;
background: linear-gradient(180deg, rgba(255, 253, 248, 0.8) 0%, rgba(255, 255, 255, 0.96) 100%);
border-color: rgba(23, 32, 51, 0.08);
}
.calendar-day-week {
@apply min-h-[22rem];
}
.calendar-day-outside {
opacity: 0.48;
}
.calendar-day-today {
border-color: rgba(15, 118, 110, 0.22);
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.18);
}
.day-number {
@apply mb-3 text-sm font-bold;
color: #172033;
}
.day-entries,
.item-card {
@apply flex flex-col gap-2;
}
.calendar-entry {
@apply flex flex-col gap-0.5 rounded-[1rem] border px-3 py-2 no-underline transition;
}
.calendar-entry:hover,
.item-card:hover {
transform: translateY(-1px);
}
.calendar-entry strong,
.item-card strong {
@apply text-sm font-bold;
color: #172033;
}
.calendar-entry span {
@apply text-xs leading-5;
color: #526178;
}
.entry-time {
@apply text-[0.7rem] font-bold uppercase tracking-[0.12em];
color: #0f766e;
}
.entry-more,
.day-empty {
@apply px-1 text-xs font-semibold;
color: #526178;
}
.calendar-entry.production {
background: #fff7ed;
border-color: rgba(249, 115, 22, 0.18);
}
.calendar-entry.approval {
background: #eff6ff;
border-color: rgba(37, 99, 235, 0.16);
}
.calendar-entry.ready {
background: #ecfdf5;
border-color: rgba(5, 150, 105, 0.16);
}
.calendar-entry.risk {
background: #fef2f2;
border-color: rgba(220, 38, 38, 0.16);
}
.calendar-entry.campaign {
background: #f8fafc;
border-color: rgba(71, 85, 105, 0.18);
border-style: dashed;
}
.calendar-entry.published,
.calendar-entry.muted {
background: #f8fafc;
border-color: rgba(148, 163, 184, 0.18);
}
.item-grid {
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-3;
}
.item-card {
@apply flex flex-col gap-4 p-5 no-underline transition;
}
.item-card:hover {
transform: translateY(-1px);
}
.item-card strong {
color: #172033;
@apply gap-4 p-5 no-underline transition;
}
.version-chip {
@@ -143,4 +673,45 @@
.status-row {
@apply flex items-center justify-between gap-3;
}
@media (max-width: 960px) {
.calendar-grid {
gap: 0.5rem;
}
.weekday-label {
@apply text-[0.65rem];
}
.calendar-day {
@apply min-h-[7rem] p-2;
}
.calendar-day-week {
@apply min-h-[18rem];
}
.calendar-entry {
@apply px-2 py-2;
}
}
@media (max-width: 720px) {
.calendar-toolbar {
@apply items-stretch;
}
.calendar-nav {
@apply justify-between;
}
.calendar-grid-head,
.calendar-grid {
min-width: 46rem;
}
.calendar-card {
overflow-x: auto;
}
}
</style>

View File

@@ -26,14 +26,32 @@
<router-link
class="site-login"
to="/login"
:to="authLink"
>
Login
{{ authLabel }}
</router-link>
</div>
</header>
</template>
<script setup>
import { computed } from 'vue';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
const authStore = useAuthStore();
const authLink = computed(() =>
authStore.isAuthenticated
? '/app/dashboard'
: '/login'
);
const authLabel = computed(() =>
authStore.isAuthenticated
? 'Open app'
: 'Login'
);
</script>
<style scoped>
.site-menu {
@apply sticky top-0 z-30 w-full;

View File

@@ -224,7 +224,6 @@
<strong>{{ workspace.name }}</strong>
<span>{{ workspace.timeZone }}</span>
</div>
<small>{{ workspace.slug }}</small>
</button>
<div
v-if="!organization.workspaces?.length"

View File

@@ -13,18 +13,10 @@
const form = reactive({
name: '',
slug: '',
timeZone: computedDefaultTimeZone(),
});
const formError = ref(null);
const previewSlug = computed(() => {
if (form.slug.trim()) {
return slugify(form.slug);
}
return slugify(form.name);
});
const selectedOrganizationId = computed({
get: () => organizationStore.selectedOrganizationId,
set: value => organizationStore.setSelectedOrganization(value),
@@ -34,15 +26,6 @@
return workspaceStore.activeWorkspace?.timeZone || 'America/Montreal';
}
function slugify(value) {
return (value ?? '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
async function submitForm() {
if (workspaceStore.isCreating) {
return;
@@ -51,10 +34,9 @@
formError.value = null;
const name = form.name.trim();
const slug = slugify(form.slug || form.name);
const timeZone = form.timeZone.trim();
if (!name || !slug || !timeZone || !selectedOrganizationId.value) {
if (!name || !timeZone || !selectedOrganizationId.value) {
formError.value = t('workspaceCreate.errors.required');
return;
}
@@ -63,7 +45,6 @@
await workspaceStore.createWorkspace({
organizationId: selectedOrganizationId.value,
name,
slug,
timeZone,
});
@@ -86,12 +67,6 @@
<h1>{{ t('workspaceCreate.title') }}</h1>
<p>{{ t('workspaceCreate.description') }}</p>
</div>
<div class="hero-note">
<strong>{{ t('workspaceCreate.previewTitle') }}</strong>
<span>{{ t('workspaceCreate.previewDescription') }}</span>
<code>{{ previewSlug || 'workspace-slug' }}</code>
</div>
</div>
<article class="create-card">
@@ -137,17 +112,6 @@
</select>
</label>
<label class="field">
<span>{{ t('workspaceCreate.fields.slug') }}</span>
<input
v-model="form.slug"
type="text"
:placeholder="t('workspaceCreate.fields.slugPlaceholder')"
:disabled="workspaceStore.isCreating"
/>
<small>{{ t('workspaceCreate.slugHint', { slug: previewSlug || 'workspace-slug' }) }}</small>
</label>
<label class="field">
<span>{{ t('workspaceCreate.fields.timeZone') }}</span>
<TimeZoneSelect
@@ -183,12 +147,7 @@
@apply mx-auto flex w-full max-w-6xl flex-col gap-6 px-5 py-8 md:px-8;
}
.hero {
@apply grid gap-4 lg:grid-cols-[minmax(0,1.3fr)_minmax(18rem,0.8fr)];
}
.hero-copy,
.hero-note,
.create-card {
@apply rounded-[1.75rem] border;
border-color: rgba(23, 32, 51, 0.08);
@@ -213,33 +172,20 @@
}
.hero-copy p,
.hero-note span,
.card-header span,
.field small {
@apply text-sm leading-6;
color: #526178;
}
.hero-note,
.create-card {
@apply flex flex-col gap-4 p-6;
}
.hero-note strong,
.card-header strong {
color: #172033;
}
.hero-note strong {
@apply text-xl font-black;
}
.hero-note code {
@apply rounded-[1rem] px-3 py-2 text-sm;
background: rgba(23, 32, 51, 0.06);
color: #172033;
}
.card-header {
@apply flex flex-col gap-2;
}

View File

@@ -114,7 +114,7 @@
}
.side-menu-left {
@apply justify-start;
@apply justify-start pl-3;
}
.side-menu-right {

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
@@ -13,8 +13,6 @@
mdiBellOutline,
mdiCalendarMonthOutline,
mdiChevronDown,
mdiCogOutline,
mdiFileDocumentOutline,
mdiFolderOutline,
mdiHomeOutline,
mdiImageMultipleOutline,
@@ -45,14 +43,13 @@
const notificationsRef = ref(null);
const searchRef = ref(null);
const collapsedSearchInputRef = ref(null);
const collapsedSearchPanelStyle = ref({});
const primaryLinks = [
{ to: '/app/dashboard', labelKey: 'nav.overview', icon: mdiHomeOutline },
{ to: '/app/workspace', labelKey: 'nav.workspacePlan', icon: mdiCalendarMonthOutline },
{ to: '/app/media-library', labelKey: 'nav.mediaLibrary', icon: mdiImageMultipleOutline },
{ to: '/app/my-feedback', labelKey: 'nav.myFeedback', icon: mdiBugOutline },
{ to: '/app/feedback', labelKey: 'nav.feedbackReview', icon: mdiBugOutline, roles: ['developer'] },
{ to: '/app/workspace-settings', labelKey: 'nav.settings', icon: mdiCogOutline },
];
const visiblePrimaryLinks = computed(() =>
primaryLinks.filter(link => !link.roles || authStore.hasAnyRole(link.roles))
@@ -102,6 +99,9 @@
campaignResults.value.length > 0 || contentResults.value.length > 0
);
const isSearchOpen = computed(() => isSearchFocused.value && normalizedSearchQuery.value.length > 0);
const isSearchPanelOpen = computed(() =>
isSearchFocused.value && (!props.isExpanded || normalizedSearchQuery.value.length > 0)
);
const notificationTitleMap = computed(() => ({
'approval.requested': t('notifications.events.approvalRequested'),
@@ -127,6 +127,34 @@
isNotificationsOpen.value = !isNotificationsOpen.value;
}
function updateCollapsedSearchPanelPosition() {
if (props.isExpanded || !searchRef.value) {
collapsedSearchPanelStyle.value = {};
return;
}
const rect = searchRef.value.getBoundingClientRect();
const left = rect.right + 12;
const availableWidth = Math.max(240, window.innerWidth - left - 12);
collapsedSearchPanelStyle.value = {
left: `${left}px`,
top: `${Math.max(12, rect.top)}px`,
width: `${Math.min(352, availableWidth)}px`,
};
}
async function openCollapsedSearch() {
if (props.isExpanded) {
return;
}
isSearchFocused.value = true;
updateCollapsedSearchPanelPosition();
await nextTick();
collapsedSearchInputRef.value?.focus();
}
function formatNotificationTitle(notification) {
return notificationTitleMap.value[notification.eventType] ?? notification.message;
}
@@ -181,12 +209,35 @@
{ immediate: true }
);
watch(isSearchPanelOpen, isOpen => {
if (isOpen) {
updateCollapsedSearchPanelPosition();
}
});
watch(
() => props.isExpanded,
isExpanded => {
if (isExpanded) {
collapsedSearchPanelStyle.value = {};
return;
}
isNotificationsOpen.value = false;
updateCollapsedSearchPanelPosition();
}
);
onMounted(() => {
document.addEventListener('click', handleDocumentClick);
window.addEventListener('resize', updateCollapsedSearchPanelPosition);
window.addEventListener('scroll', updateCollapsedSearchPanelPosition, true);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick);
window.removeEventListener('resize', updateCollapsedSearchPanelPosition);
window.removeEventListener('scroll', updateCollapsedSearchPanelPosition, true);
});
</script>
@@ -222,6 +273,8 @@
<label
class="sidebar-search"
:class="{ 'sidebar-search-open': isSearchOpen }"
:title="!isExpanded ? 'Search' : null"
@click="openCollapsedSearch"
>
<v-icon
:icon="mdiMagnify"
@@ -238,9 +291,28 @@
</label>
<div
v-if="isSearchOpen"
v-if="isSearchPanelOpen"
class="sidebar-floating-panel"
:class="{ 'sidebar-search-panel-collapsed': !isExpanded }"
:style="!isExpanded ? collapsedSearchPanelStyle : null"
>
<label
v-if="!isExpanded"
class="sidebar-search sidebar-search-panel-input"
>
<v-icon
:icon="mdiMagnify"
class="sidebar-search-icon"
/>
<input
ref="collapsedSearchInputRef"
v-model="searchQuery"
type="search"
class="sidebar-search-input"
placeholder="Search"
/>
</label>
<div
v-if="campaignResults.length"
class="sidebar-search-group"
@@ -274,7 +346,7 @@
</div>
<div
v-if="!hasSearchResults"
v-if="normalizedSearchQuery.length > 0 && !hasSearchResults"
class="sidebar-search-empty"
>
No results found.
@@ -383,7 +455,7 @@
:title="!isExpanded ? t('nav.content') : null"
>
<span class="sidebar-link-main">
<v-icon :icon="mdiFileDocumentOutline" />
<v-icon :icon="mdiCalendarMonthOutline" />
<span
v-if="isExpanded"
class="sidebar-link-label"
@@ -411,6 +483,7 @@
class="sidebar-link sidebar-link-section"
active-class="sidebar-link-active"
:title="!isExpanded ? t('nav.campaigns') : null"
@click="toggleSection('campaigns')"
>
<span class="sidebar-link-main">
<v-icon :icon="mdiFolderOutline" />
@@ -420,6 +493,12 @@
>
{{ t('nav.campaigns') }}
</span>
<v-icon
v-if="isExpanded"
:icon="mdiChevronDown"
class="sidebar-chevron"
:class="{ 'sidebar-chevron-open': openSections.campaigns }"
/>
</span>
</router-link>
@@ -431,19 +510,6 @@
>
<v-icon :icon="mdiPlus" />
</router-link>
<button
v-if="isExpanded"
class="sidebar-section-toggle"
type="button"
@click="toggleSection('campaigns')"
>
<v-icon
:icon="mdiChevronDown"
class="sidebar-chevron"
:class="{ 'sidebar-chevron-open': openSections.campaigns }"
/>
</button>
</div>
<div
@@ -484,6 +550,7 @@
class="sidebar-link sidebar-link-section"
active-class="sidebar-link-active"
:title="!isExpanded ? t('nav.channels') : null"
@click="toggleSection('channels')"
>
<span class="sidebar-link-main">
<v-icon :icon="mdiLan" />
@@ -493,6 +560,12 @@
>
{{ t('nav.channels') }}
</span>
<v-icon
v-if="isExpanded"
:icon="mdiChevronDown"
class="sidebar-chevron"
:class="{ 'sidebar-chevron-open': openSections.channels }"
/>
</span>
</router-link>
@@ -504,19 +577,6 @@
>
<v-icon :icon="mdiPlus" />
</router-link>
<button
v-if="isExpanded"
class="sidebar-section-toggle"
type="button"
@click="toggleSection('channels')"
>
<v-icon
:icon="mdiChevronDown"
class="sidebar-chevron"
:class="{ 'sidebar-chevron-open': openSections.channels }"
/>
</button>
</div>
<div
@@ -566,20 +626,25 @@
}
.app-sidebar-scroll {
@apply flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto pb-4 pt-3 pr-3;
@apply flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto pb-4 pt-4;
}
.brand-block {
@apply flex items-center gap-3;
@apply flex items-center gap-3 pb-4;
border-bottom: 1px solid rgba(23, 32, 51, 0.08);
}
.brand-link {
@apply flex items-center gap-3 no-underline;
@apply flex min-w-0 items-center gap-3 no-underline;
color: inherit;
}
.brand-link-collapsed {
@apply w-full justify-center;
}
.brand-mark {
@apply flex h-11 w-11 items-center justify-center rounded-2xl text-lg font-black;
@apply flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[1.1rem] text-xl font-black;
background: linear-gradient(135deg, #ff8a3d 0%, #ef4444 100%);
color: #fffaf2;
}
@@ -621,7 +686,7 @@
}
.sidebar-search-icon {
@apply text-xl;
@apply h-5 w-5 flex-shrink-0 text-xl;
}
.sidebar-search-input {
@@ -641,6 +706,14 @@
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
}
.sidebar-search-panel-collapsed {
@apply fixed right-auto top-auto;
}
.sidebar-search-panel-input {
@apply bg-white;
}
.sidebar-search-group {
@apply flex flex-col gap-1;
}
@@ -761,18 +834,12 @@
@apply truncate;
}
.sidebar-section-toggle {
@apply flex h-11 w-11 items-center justify-center rounded-[1rem] transition-colors;
color: #526178;
}
.sidebar-section-action {
@apply flex h-11 w-11 items-center justify-center rounded-[1rem] transition-colors no-underline;
@apply ml-auto flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[1rem] transition-colors no-underline;
color: #526178;
}
.sidebar-section-action:hover,
.sidebar-section-toggle:hover {
.sidebar-section-action:hover {
background: rgba(23, 32, 51, 0.06);
color: #172033;
}
@@ -785,8 +852,9 @@
transform: rotate(180deg);
}
.sidebar-link :deep(.v-icon) {
@apply text-xl;
.sidebar-link :deep(.v-icon),
.sidebar-section-action :deep(.v-icon) {
@apply h-5 w-5 flex-shrink-0 text-xl;
}
.sidebar-sublist {
@@ -818,10 +886,30 @@
@apply w-[5.5rem] px-3;
}
.app-sidebar-collapsed .brand-block {
@apply justify-center;
}
.app-sidebar-collapsed .sidebar-search {
@apply justify-center px-0;
}
.app-sidebar-collapsed .sidebar-link {
@apply justify-center px-0;
}
.app-sidebar-collapsed .sidebar-link-main {
@apply justify-center;
}
.app-sidebar-collapsed .sidebar-search:hover {
background: rgba(23, 32, 51, 0.07);
}
.app-sidebar-collapsed .sidebar-search-panel-input {
@apply justify-start px-4;
}
.app-sidebar-collapsed .sidebar-floating-panel {
left: calc(100% + 0.75rem);
right: auto;

View File

@@ -6,7 +6,13 @@
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useLanguageStore } from '@/stores/languageStore.js';
import { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js';
import { mdiChevronDown } from '@mdi/js';
import {
mdiAccountCircleOutline,
mdiBugOutline,
mdiChevronDown,
mdiLogout,
mdiTranslate,
} from '@mdi/js';
const props = defineProps({
isExpanded: {
@@ -16,7 +22,7 @@
});
const router = useRouter();
const { t } = useI18n();
const { locale, t } = useI18n();
const authStore = useAuthStore();
const languageStore = useLanguageStore();
const userProfileStore = useUserProfileStore();
@@ -24,16 +30,13 @@
const userMenuRef = ref(null);
function toggleUserMenu() {
if (!props.isExpanded) {
return;
}
isUserMenuOpen.value = !isUserMenuOpen.value;
}
function toggleLanguage() {
const nextLocale = languageStore.locale === 'en' ? 'fr' : 'en';
const nextLocale = locale.value === 'en' ? 'fr' : 'en';
languageStore.setLocale(nextLocale);
locale.value = nextLocale;
isUserMenuOpen.value = false;
}
@@ -42,6 +45,11 @@
await router.push({ name: 'settings-user-information' });
}
async function openMyFeedback() {
isUserMenuOpen.value = false;
await router.push({ name: 'my-feedback' });
}
function handleLogout() {
isUserMenuOpen.value = false;
authStore.logout();
@@ -75,6 +83,7 @@
<div
ref="userMenuRef"
class="sidebar-workspace sidebar-workspace-bottom"
:class="{ 'sidebar-workspace-collapsed': !isExpanded }"
>
<button
class="sidebar-workspace-trigger"
@@ -102,29 +111,42 @@
</button>
<div
v-if="isExpanded && isUserMenuOpen"
v-if="isUserMenuOpen"
class="sidebar-workspace-menu"
>
<button
class="sidebar-workspace-option"
type="button"
@click="openMyFeedback"
>
<v-icon :icon="mdiBugOutline" />
<span>{{ t('nav.myFeedback') }}</span>
</button>
<div class="sidebar-workspace-separator" />
<button
class="sidebar-workspace-option"
type="button"
@click="openProfile"
>
{{ t('nav.profile') }}
<v-icon :icon="mdiAccountCircleOutline" />
<span>{{ t('nav.profile') }}</span>
</button>
<button
class="sidebar-workspace-option"
type="button"
@click="toggleLanguage"
>
{{ t('nav.language') }}
<v-icon :icon="mdiTranslate" />
<span>{{ t('nav.language') }}</span>
</button>
<div class="sidebar-workspace-separator" />
<button
class="sidebar-workspace-option sidebar-workspace-option-danger"
type="button"
@click="handleLogout"
>
{{ t('nav.signOut') }}
<v-icon :icon="mdiLogout" />
<span>{{ t('nav.signOut') }}</span>
</button>
</div>
</div>
@@ -172,16 +194,43 @@
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
}
.sidebar-workspace-collapsed {
@apply items-center;
}
.sidebar-workspace-collapsed .sidebar-workspace-trigger {
@apply h-11 w-11 justify-center rounded-[1rem] p-0;
}
.sidebar-workspace-collapsed .sidebar-workspace-menu {
@apply left-[calc(100%+0.75rem)] right-auto w-56;
bottom: 1rem;
}
.sidebar-workspace-option {
@apply rounded-[0.95rem] px-4 py-3 text-left text-sm font-semibold transition-colors;
@apply flex items-center gap-3 rounded-[0.95rem] px-4 py-3 text-left text-sm font-semibold transition-colors;
color: #172033;
}
.sidebar-workspace-option .v-icon {
@apply text-base;
color: #5d6b82;
}
.sidebar-workspace-option:hover {
background: rgba(23, 32, 51, 0.05);
}
.sidebar-workspace-separator {
@apply my-1;
border-top: 1px solid rgba(23, 32, 51, 0.08);
}
.sidebar-workspace-option-danger {
color: #b91c1c;
}
.sidebar-workspace-option-danger .v-icon {
color: #b91c1c;
}
</style>

View File

@@ -93,6 +93,16 @@
await router.push({ name: 'workspace-create' });
}
async function openWorkspaceSettings(workspaceId) {
if (workspaceId) {
workspaceStore.setActiveWorkspace(workspaceId);
}
isWorkspaceMenuOpen.value = false;
isOrganizationListOpen.value = false;
await router.push({ name: 'workspace-settings' });
}
async function openOrganizationSettings(organizationId) {
isWorkspaceMenuOpen.value = false;
isOrganizationListOpen.value = false;
@@ -143,23 +153,38 @@
v-if="isWorkspaceMenuOpen"
class="user-menu"
>
<button
<div
v-for="workspace in visibleWorkspaces"
:key="workspace.id"
class="user-menu-item"
class="workspace-menu-row"
:class="{ 'user-menu-item-active': workspace.id === workspaceStore.activeWorkspaceId }"
@click="chooseWorkspace(workspace.id)"
>
<AppAvatar
:name="workspace.name"
:src="workspace.logoUrl"
size="sm"
/>
<span class="user-menu-item-copy">
<span>{{ workspace.name }}</span>
<small>{{ workspace.timeZone }}</small>
</span>
</button>
<button
class="user-menu-item workspace-menu-select"
type="button"
@click="chooseWorkspace(workspace.id)"
>
<AppAvatar
:name="workspace.name"
:src="workspace.logoUrl"
size="sm"
/>
<span class="user-menu-item-copy">
<span>{{ workspace.name }}</span>
<small>{{ workspace.timeZone }}</small>
</span>
</button>
<button
v-if="canManageWorkspaces"
class="workspace-settings-button"
type="button"
:aria-label="t('workspaceSelector.workspaceSettings')"
@click="openWorkspaceSettings(workspace.id)"
>
<v-icon :icon="mdiCogOutline" />
</button>
</div>
<button
v-if="canManageWorkspaces"
@@ -270,7 +295,9 @@
}
.user-menu {
@apply absolute right-0 top-[calc(100%+0.75rem)] flex max-h-[80vh] min-w-[17rem] flex-col gap-1 overflow-y-auto rounded-[1.25rem] border p-2;
@apply absolute left-0 top-[calc(100%+0.75rem)] flex max-h-[80vh] min-w-[17rem] flex-col gap-1 overflow-y-auto rounded-[1.25rem] border p-2;
width: max(100%, 17rem);
max-width: min(24rem, calc(100vw - 2rem));
isolation: isolate;
background: #fffdf8;
background-clip: padding-box;
@@ -284,7 +311,8 @@
color: #172033;
}
.user-menu-item:hover {
.user-menu-item:hover,
.workspace-menu-row:hover {
background: rgba(23, 32, 51, 0.06);
}
@@ -293,6 +321,33 @@
color: #c2410c;
}
.workspace-menu-row {
@apply flex min-w-0 items-center rounded-[0.9rem] transition-colors;
color: #172033;
}
.workspace-menu-select {
@apply min-w-0 flex-1;
}
.workspace-menu-select:hover {
background: transparent;
}
.workspace-settings-button {
@apply mr-2 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full transition-colors;
color: #526178;
}
.workspace-settings-button:hover {
background: rgba(23, 32, 51, 0.1);
color: #172033;
}
.workspace-settings-button :deep(.v-icon) {
font-size: 1rem;
}
.user-menu-item-copy {
@apply flex min-w-0 flex-1 flex-col gap-0.5;
}

View File

@@ -42,24 +42,20 @@
"createAction": "Add workspace",
"organizationLabel": "Organization",
"organizationSettings": "Organization settings",
"noOrganization": "No organization"
"noOrganization": "No organization",
"workspaceSettings": "Workspace settings"
},
"workspaceCreate": {
"eyebrow": "Workspace",
"title": "Create a new workspace",
"description": "Set up a new workspace with its own slug, timezone, members, workflow, and connectors.",
"previewTitle": "Workspace URL",
"previewDescription": "The slug becomes the stable identifier used for the workspace.",
"description": "Set up a new workspace with its own timezone, members, workflow, and connectors.",
"formTitle": "Workspace details",
"formDescription": "Start with the core fields now. Members, workflow, and connectors can be configured right after creation.",
"createAction": "Create workspace",
"slugHint": "Workspace slug preview: {slug}",
"fields": {
"name": "Workspace name",
"namePlaceholder": "Northwind Studio",
"organization": "Organization",
"slug": "Workspace slug",
"slugPlaceholder": "northwind-studio",
"timeZone": "Time zone"
},
"errors": {
@@ -131,7 +127,7 @@
"notifications": "Notifications",
"dashboard": "Dashboard",
"overview": "Overview",
"workspacePlan": "Content",
"workspacePlan": "Calendar",
"mediaLibrary": "Media Library",
"myFeedback": "My Feedback",
"feedbackReview": "Feedback Review",
@@ -493,6 +489,7 @@
"description": "Reviewable units with assets, copy, and approval status inside the active workspace.",
"newItem": "New content item",
"createTitle": "Create content item",
"upcoming": "Upcoming",
"loading": "Loading content items...",
"empty": "No content items are available for the active workspace.",
"noDueDate": "No due date",

View File

@@ -42,24 +42,20 @@
"createAction": "Ajouter un espace",
"organizationLabel": "Organisation",
"organizationSettings": "Parametres de l'organisation",
"noOrganization": "Aucune organisation"
"noOrganization": "Aucune organisation",
"workspaceSettings": "Parametres de l'espace"
},
"workspaceCreate": {
"eyebrow": "Espace",
"title": "Creer un nouvel espace",
"description": "Configurez un nouvel espace avec son slug, son fuseau horaire, ses membres, son workflow et ses connecteurs.",
"previewTitle": "URL de l'espace",
"previewDescription": "Le slug devient l'identifiant stable utilise pour l'espace.",
"description": "Configurez un nouvel espace avec son fuseau horaire, ses membres, son workflow et ses connecteurs.",
"formTitle": "Details de l'espace",
"formDescription": "Commencez par les champs essentiels. Les membres, le workflow et les connecteurs peuvent etre configures juste apres la creation.",
"createAction": "Creer l'espace",
"slugHint": "Apercu du slug : {slug}",
"fields": {
"name": "Nom de l'espace",
"namePlaceholder": "Northwind Studio",
"organization": "Organisation",
"slug": "Slug de l'espace",
"slugPlaceholder": "northwind-studio",
"timeZone": "Fuseau horaire"
},
"errors": {
@@ -131,7 +127,7 @@
"notifications": "Notifications",
"dashboard": "Tableau de bord",
"overview": "Vue globale",
"workspacePlan": "Contenu",
"workspacePlan": "Calendrier",
"mediaLibrary": "Bibliotheque media",
"myFeedback": "Mon feedback",
"feedbackReview": "Revue feedback",
@@ -493,6 +489,7 @@
"description": "Unités révisables avec ressources, texte et statut d'approbation dans l'espace actif.",
"newItem": "Nouvel élément de contenu",
"createTitle": "Créer un élément de contenu",
"upcoming": "À venir",
"loading": "Chargement des éléments de contenu...",
"empty": "Aucun élément de contenu n'est disponible pour l'espace actif.",
"noDueDate": "Aucune échéance",

View File

@@ -12,7 +12,6 @@ const ForgotPasswordView = () => import('@/features/auth/views/ForgotPasswordVie
const ResetPasswordView = () => import('@/features/auth/views/ResetPasswordView.vue');
const VerifyEmailView = () => import('@/features/auth/views/VerifyEmailView.vue');
const OverviewView = () => import('@/features/workspaces/views/OverviewView.vue');
const DashboardView = () => import('@/features/workspaces/views/DashboardView.vue');
const ChannelsView = () => import('@/features/channels/views/ChannelsView.vue');
const CampaignsView = () => import('@/features/campaigns/views/CampaignsView.vue');
const CampaignDetailView = () => import('@/features/campaigns/views/CampaignDetailView.vue');
@@ -70,7 +69,7 @@ const routes = [
{
path: '/app/workspace',
name: 'workspace-dashboard',
component: DashboardView,
redirect: { name: 'content-items' },
meta: { requiresAuth: true },
},
{