feat: just getting better and better
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, '');
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -224,7 +224,6 @@
|
||||
<strong>{{ workspace.name }}</strong>
|
||||
<span>{{ workspace.timeZone }}</span>
|
||||
</div>
|
||||
<small>{{ workspace.slug }}</small>
|
||||
</button>
|
||||
<div
|
||||
v-if="!organization.workspaces?.length"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user