feat: refine content calendar experience

This commit is contained in:
2026-05-05 23:25:58 -04:00
parent b66c10b681
commit a7535d460d
72 changed files with 3233 additions and 1310 deletions

View File

@@ -1,6 +1,9 @@
FROM node:22-alpine AS build
WORKDIR /app
ARG VITE_API_URL=/api
ENV VITE_API_URL=$VITE_API_URL
COPY frontend/package*.json ./
RUN npm ci

View File

@@ -116,6 +116,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/organizations/{organizationId}/logo": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesOrganizationsHandlersChangeOrganizationLogoHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/organizations/{organizationId}": {
parameters: {
query?: never;
@@ -708,6 +724,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/content-items/{id}/activity": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesContentItemsHandlersGetContentItemActivityHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/content-items/{id}/status": {
parameters: {
query?: never;
@@ -740,22 +772,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/comments/{id}/resolve": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesCommentsHandlersResolveCommentHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/clients/{id}/portrait": {
parameters: {
query?: never;
@@ -868,6 +884,118 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/calendar-integrations/catalog": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesCalendarIntegrationsHandlersListCalendarCatalogHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/calendar-integrations/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesCalendarIntegrationsHandlersListCalendarEventsHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/calendar-integrations/sources/{sourceId}/refresh": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesCalendarIntegrationsHandlersRefreshCalendarSourceHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/calendar-integrations/export-feed": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesCalendarIntegrationsHandlersGetUserCalendarExportFeedHandler"];
put?: never;
post?: never;
delete: operations["SocializeApiModulesCalendarIntegrationsHandlersRevokeUserCalendarExportFeedHandler"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/calendar-integrations/export-feed/enable": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesCalendarIntegrationsHandlersEnableUserCalendarExportFeedHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/calendar-integrations/export-feed/regenerate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesCalendarIntegrationsHandlersRegenerateUserCalendarExportFeedHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/calendar-integrations/export-feed/{token}.ics": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesCalendarIntegrationsHandlersGetUserCalendarExportFeedIcsHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/assets/{id}/revisions": {
parameters: {
query?: never;
@@ -1067,18 +1195,38 @@ export interface components {
email: string;
role: string;
};
SocializeApiModulesOrganizationsHandlersChangeOrganizationLogoResponse: {
blobUrl?: string;
};
SocializeApiModulesOrganizationsHandlersChangeOrganizationLogoRequest: {
/** Format: binary */
file: string;
};
SocializeApiModulesOrganizationsHandlersOrganizationDto: {
/** Format: guid */
id?: string;
name?: string;
logoUrl?: string | null;
/** Format: guid */
ownerUserId?: string;
currentUserPermissions?: string[];
members?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMemberDto"][];
workspaces?: components["schemas"]["SocializeApiModulesWorkspacesHandlersWorkspaceDto"][];
usage?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationUsageDto"] | null;
/** Format: date-time */
createdAt?: string;
};
SocializeApiModulesOrganizationsHandlersOrganizationUsageDto: {
planName?: string;
items?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationUsageItemDto"][];
};
SocializeApiModulesOrganizationsHandlersOrganizationUsageItemDto: {
key?: string;
/** Format: int32 */
used?: number;
/** Format: int32 */
limit?: number | null;
};
SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest: {
name: string;
};
@@ -1386,6 +1534,8 @@ export interface components {
publicationTargets: string;
hashtags?: string | null;
changeSummary?: string | null;
/** Format: date-time */
dueDate?: string | null;
};
SocializeApiModulesContentItemsHandlersContentItemDetailDto: {
/** Format: guid */
@@ -1409,6 +1559,25 @@ export interface components {
/** Format: date-time */
createdAt?: string;
};
SocializeApiModulesContentItemsHandlersContentItemActivityEntryDto: {
/** Format: guid */
id?: string;
/** Format: guid */
workspaceId?: string;
/** Format: guid */
contentItemId?: string;
eventType?: string;
entityType?: string;
/** Format: guid */
entityId?: string;
summary?: string;
/** Format: guid */
actorUserId?: string | null;
actorEmail?: string | null;
metadataJson?: string | null;
/** Format: date-time */
createdAt?: string;
};
SocializeApiModulesContentItemsHandlersGetContentItemsRequest: Record<string, never>;
SocializeApiModulesContentItemsHandlersUpdateContentItemStatusRequest: {
status: string;
@@ -1428,11 +1597,13 @@ export interface components {
authorEmail?: string;
authorPortraitUrl?: string | null;
body?: string;
isResolved?: boolean;
attachmentFileName?: string | null;
attachmentContentType?: string | null;
/** Format: int64 */
attachmentSizeBytes?: number | null;
attachmentBlobUrl?: string | null;
/** Format: date-time */
createdAt?: string;
/** Format: date-time */
resolvedAt?: string | null;
};
SocializeApiModulesCommentsHandlersCreateCommentRequest: {
/** Format: guid */
@@ -1441,7 +1612,9 @@ export interface components {
contentItemId: string;
/** Format: guid */
parentCommentId?: string | null;
body: string;
body?: string;
/** Format: binary */
attachment?: string | null;
};
SocializeApiModulesCommentsHandlersGetCommentsRequest: Record<string, never>;
SocializeApiModulesClientsHandlersChangeClientPortraitResponse: {
@@ -1576,7 +1749,65 @@ export interface components {
isEnabled?: boolean;
inheritanceMode?: string | null;
};
SocializeApiModulesCalendarIntegrationsHandlersCalendarCatalogEntryDto: {
/** Format: guid */
id?: string;
title?: string;
description?: string;
country?: string | null;
region?: string | null;
language?: string;
category?: string;
cultureOrReligion?: string | null;
providerName?: string;
sourceUrl?: string;
trustLevel?: string;
defaultColor?: string;
};
SocializeApiModulesCalendarIntegrationsHandlersListCalendarCatalogRequest: Record<string, never>;
SocializeApiModulesCalendarIntegrationsHandlersCalendarEventDto: {
/** Format: guid */
id?: string;
/** Format: guid */
calendarSourceId?: string;
sourceEventUid?: string;
title?: string;
description?: string | null;
isAllDay?: boolean;
isFloatingTime?: boolean;
/** Format: date */
startDate?: string;
/** Format: date */
endDate?: string;
/** Format: date-time */
startLocalDateTime?: string | null;
/** Format: date-time */
endLocalDateTime?: string | null;
/** Format: date-time */
startUtc?: string | null;
/** Format: date-time */
endUtc?: string | null;
timeZoneId?: string | null;
recurrenceId?: string | null;
location?: string | null;
sourceUrl?: string | null;
/** Format: date-time */
sourceLastModifiedAt?: string | null;
/** Format: date-time */
importedAt?: string;
};
SocializeApiModulesCalendarIntegrationsHandlersListCalendarEventsRequest: Record<string, never>;
SocializeApiModulesCalendarIntegrationsHandlersListCalendarSourcesRequest: Record<string, never>;
SocializeApiModulesCalendarIntegrationsHandlersUserCalendarExportFeedDto: {
isEnabled?: boolean;
feedUrl?: string | null;
/** Format: date-time */
createdAt?: string | null;
/** Format: date-time */
updatedAt?: string | null;
/** Format: date-time */
revokedAt?: string | null;
};
SocializeApiModulesAssetsHandlersAssetRevisionDto: {
/** Format: guid */
id?: string;
@@ -2001,6 +2232,48 @@ export interface operations {
};
};
};
SocializeApiModulesOrganizationsHandlersChangeOrganizationLogoHandler: {
parameters: {
query?: never;
header?: never;
path: {
organizationId: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["SocializeApiModulesOrganizationsHandlersChangeOrganizationLogoRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersChangeOrganizationLogoResponse"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesOrganizationsHandlersGetOrganizationHandler: {
parameters: {
query?: never;
@@ -3353,6 +3626,35 @@ export interface operations {
};
};
};
SocializeApiModulesContentItemsHandlersGetContentItemActivityHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesContentItemsHandlersContentItemActivityEntryDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesContentItemsHandlersUpdateContentItemStatusHandler: {
parameters: {
query?: never;
@@ -3433,7 +3735,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesCommentsHandlersCreateCommentRequest"];
"multipart/form-data": components["schemas"]["SocializeApiModulesCommentsHandlersCreateCommentRequest"];
};
};
responses: {
@@ -3464,35 +3766,6 @@ export interface operations {
};
};
};
SocializeApiModulesCommentsHandlersResolveCommentHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesCommentsHandlersCommentDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesClientsHandlersChangeClientPortraitHandler: {
parameters: {
query?: never;
@@ -3923,6 +4196,229 @@ export interface operations {
};
};
};
SocializeApiModulesCalendarIntegrationsHandlersListCalendarCatalogHandler: {
parameters: {
query?: {
search?: string | null;
country?: string | null;
region?: string | null;
language?: string | null;
category?: string | null;
cultureOrReligion?: string | null;
provider?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersCalendarCatalogEntryDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesCalendarIntegrationsHandlersListCalendarEventsHandler: {
parameters: {
query?: {
workspaceId?: string | null;
startDate?: string | null;
endDate?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersCalendarEventDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesCalendarIntegrationsHandlersRefreshCalendarSourceHandler: {
parameters: {
query?: never;
header?: never;
path: {
sourceId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersCalendarSourceDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesCalendarIntegrationsHandlersGetUserCalendarExportFeedHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersUserCalendarExportFeedDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesCalendarIntegrationsHandlersRevokeUserCalendarExportFeedHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersUserCalendarExportFeedDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesCalendarIntegrationsHandlersEnableUserCalendarExportFeedHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersUserCalendarExportFeedDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesCalendarIntegrationsHandlersRegenerateUserCalendarExportFeedHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersUserCalendarExportFeedDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesCalendarIntegrationsHandlersGetUserCalendarExportFeedIcsHandler: {
parameters: {
query?: never;
header?: never;
path: {
token: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesAssetsHandlersCreateAssetRevisionHandler: {
parameters: {
query?: never;

View File

@@ -5,6 +5,10 @@
:root {
--socialize-primary: #172033;
--socialize-accent: #ff8a3d;
--socialize-accent-strong: #ef4444;
--socialize-brand-gradient: linear-gradient(135deg, var(--socialize-accent) 0%, var(--socialize-accent-strong) 100%);
--socialize-accent-shadow: rgba(255, 138, 61, 0.28);
--socialize-accent-strong-shadow: rgba(239, 68, 68, 0.28);
--socialize-highlight: #2fa58d;
--h-background: #f4f6f3;
--h-on-background: #172033;

View File

@@ -218,7 +218,7 @@
.login-brand-mark {
@apply flex h-11 w-11 items-center justify-center rounded-2xl text-lg font-black;
background: linear-gradient(135deg, #ff8a3d 0%, #ef4444 100%);
background: var(--socialize-brand-gradient);
color: #fffaf2;
}

View File

@@ -0,0 +1,69 @@
<script setup>
const props = defineProps({
modelValue: {
type: String,
default: '#2F80ED',
},
colors: {
type: Array,
default: () => [
'#2F80ED',
'#0F766E',
'#16A34A',
'#F59E0B',
'#EF4444',
'#EC4899',
'#8B5CF6',
'#475569',
],
},
});
const emit = defineEmits(['update:modelValue']);
</script>
<template>
<div class="color-palette">
<button
v-for="color in props.colors"
:key="color"
class="color-option"
:class="{ active: color.toLowerCase() === props.modelValue.toLowerCase() }"
type="button"
:style="{ background: color }"
:aria-label="color"
@click="emit('update:modelValue', color)"
/>
</div>
</template>
<style scoped>
.color-palette {
display: grid;
grid-template-columns: repeat(4, 1.75rem);
gap: 0.5rem;
width: max-content;
padding: 0.5rem;
border: 1px solid rgba(23, 32, 51, 0.1);
border-radius: 0.75rem;
background: #ffffff;
border-color: rgba(23, 32, 51, 0.1);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.14);
}
.color-option {
width: 1.75rem;
height: 1.75rem;
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 9999px;
border-color: rgba(255, 255, 255, 0.9);
box-shadow: 0 0 0 1px rgba(23, 32, 51, 0.12);
transition: box-shadow 0.15s ease, transform 0.15s ease;
}
.color-option:hover,
.color-option.active {
transform: scale(1.08);
box-shadow: 0 0 0 2px #172033;
}
</style>

View File

@@ -0,0 +1,389 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { mdiAt, mdiClose, mdiImagePlusOutline, mdiLockOutline, mdiSend } from '@mdi/js';
import AppAvatar from '@/components/AppAvatar.vue';
import { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js';
const props = defineProps({
members: {
type: Array,
default: () => [],
},
isPosting: {
type: Boolean,
default: false,
},
replyTarget: {
type: Object,
default: null,
},
variant: {
type: String,
default: 'default',
},
});
const emit = defineEmits(['submit-comment', 'cancel-reply']);
const userProfileStore = useUserProfileStore();
const mediaFileInput = ref(null);
const form = reactive({
body: '',
isInternal: false,
mediaFile: null,
showMentionPicker: false,
});
const currentUserEmail = computed(() => userProfileStore.user?.email ?? '');
const currentUserName = computed(() => userProfileStore.alias);
const isReplyVariant = computed(() => props.variant === 'reply');
const canSubmit = computed(() =>
Boolean(form.body.trim() || form.mediaFile) &&
!props.isPosting
);
function submitComment() {
if (!canSubmit.value) {
return;
}
const bodyParts = [];
const body = form.body.trim();
if (form.isInternal) {
bodyParts.push('[Internal]');
}
if (body) {
bodyParts.push(body);
}
emit('submit-comment', {
body: bodyParts.join('\n\n'),
isInternal: form.isInternal,
mediaReference: form.mediaFile?.name ?? null,
mediaFile: form.mediaFile,
mediaFileName: form.mediaFile?.name ?? null,
mediaFileSize: form.mediaFile?.size ?? null,
mediaFileType: form.mediaFile?.type || null,
parentCommentId: props.replyTarget?.id ?? null,
});
form.body = '';
form.isInternal = false;
form.mediaFile = null;
form.showMentionPicker = false;
if (mediaFileInput.value) {
mediaFileInput.value.value = '';
}
}
function openMediaPicker() {
mediaFileInput.value?.click();
form.showMentionPicker = false;
}
function selectMediaFile(event) {
form.mediaFile = event.target.files?.[0] ?? null;
}
function clearMediaFile() {
form.mediaFile = null;
if (mediaFileInput.value) {
mediaFileInput.value.value = '';
}
}
function toggleMentionPicker() {
form.showMentionPicker = !form.showMentionPicker;
}
function insertMention(member) {
const label = member.displayName || member.email;
if (!label) {
return;
}
const mention = `@${label.replace(/\s+/g, '')}`;
const separator = form.body && !/\s$/.test(form.body) ? ' ' : '';
form.body = `${form.body}${separator}${mention} `;
form.showMentionPicker = false;
}
</script>
<template>
<div
class="comment-composer"
:class="variant"
>
<div
v-if="replyTarget && !isReplyVariant"
class="reply-context"
>
<div>
<span>Replying to</span>
<strong>{{ replyTarget.authorDisplayName }}</strong>
</div>
<button
type="button"
title="Cancel reply"
@click="emit('cancel-reply')"
>
<v-icon :icon="mdiClose" />
</button>
</div>
<div class="comment-composer-main">
<AppAvatar
v-if="!isReplyVariant"
:name="currentUserName"
:email="currentUserEmail"
:src="userProfileStore.portraitUrl"
size="md"
/>
<textarea
v-model="form.body"
class="comment-textarea"
:placeholder="replyTarget ? 'Write a reply...' : 'Write a comment...'"
></textarea>
</div>
<div
v-if="form.mediaFile"
class="selected-media-file"
>
<span>{{ form.mediaFile.name }}</span>
<button
type="button"
title="Remove selected media"
@click="clearMediaFile"
>
<v-icon :icon="mdiClose" />
</button>
</div>
<div
v-if="form.showMentionPicker"
class="mention-picker"
>
<button
v-for="member in members"
:key="member.id"
class="mention-option"
type="button"
@click="insertMention(member)"
>
<AppAvatar
:name="member.displayName"
:email="member.email"
size="sm"
/>
<span>{{ member.displayName }}</span>
</button>
<div
v-if="!members.length"
class="empty-note"
>
No workspace members are available to mention.
</div>
</div>
<div class="comment-composer-toolbar">
<div class="comment-tool-actions">
<label
class="icon-tool-button internal-toggle"
:class="{ active: form.isInternal }"
title="Internal comment"
>
<input
v-model="form.isInternal"
type="checkbox"
/>
<v-icon :icon="mdiLockOutline" />
</label>
<button
class="icon-tool-button"
type="button"
title="Upload media from computer"
:class="{ active: form.mediaFile }"
@click="openMediaPicker"
>
<v-icon :icon="mdiImagePlusOutline" />
</button>
<input
ref="mediaFileInput"
class="sr-only"
type="file"
accept="image/png,image/jpeg,image/jpg"
@change="selectMediaFile"
/>
<button
class="icon-tool-button"
type="button"
title="Mention a member"
:class="{ active: form.showMentionPicker }"
@click="toggleMentionPicker"
>
<v-icon :icon="mdiAt" />
</button>
<button
class="post-button"
type="button"
:disabled="!canSubmit"
@click="submitComment"
>
<v-icon :icon="mdiSend" />
{{ isPosting ? 'Posting...' : 'Post' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.comment-composer {
@apply flex flex-col gap-3 rounded-[1.25rem] border p-4;
background: #fffdf8;
border-color: rgba(23, 32, 51, 0.12);
}
.comment-composer.reply {
@apply rounded-[1rem] p-3;
background: rgba(255, 253, 248, 0.84);
}
.comment-composer-main {
@apply flex items-start gap-3;
}
.reply-context {
@apply flex items-center justify-between gap-3 rounded-[1rem] border px-3 py-2;
background: rgba(15, 118, 110, 0.06);
border-color: rgba(15, 118, 110, 0.14);
}
.reply-context div {
@apply flex min-w-0 items-center gap-2 text-sm;
}
.reply-context span {
color: #526178;
}
.reply-context strong {
@apply truncate;
color: #172033;
}
.reply-context button {
@apply inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition;
color: #526178;
}
.reply-context button:hover,
.reply-context button:focus-visible {
background: rgba(15, 118, 110, 0.12);
color: #0f766e;
}
.comment-textarea {
@apply min-h-24 flex-1 resize-y border-0 bg-transparent text-sm leading-6;
color: #172033;
outline: none;
}
.comment-textarea::placeholder {
color: #7c8798;
}
.selected-media-file {
@apply flex items-center justify-between gap-3 rounded-[1rem] border px-3 py-2 text-sm;
background: rgba(23, 32, 51, 0.03);
border-color: rgba(23, 32, 51, 0.08);
}
.selected-media-file span {
@apply min-w-0 truncate font-semibold;
color: #172033;
}
.selected-media-file button {
@apply inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition;
color: #526178;
}
.selected-media-file button:hover,
.selected-media-file button:focus-visible {
background: rgba(185, 28, 28, 0.1);
color: #b91c1c;
}
.mention-picker {
@apply grid max-h-52 gap-2 overflow-y-auto rounded-[1rem] border p-2;
background: rgba(23, 32, 51, 0.03);
border-color: rgba(23, 32, 51, 0.08);
}
.mention-option {
@apply flex items-center gap-3 rounded-[0.875rem] px-2 py-2 text-left text-sm font-semibold transition;
color: #172033;
}
.mention-option:hover {
background: rgba(15, 118, 110, 0.1);
color: #0f766e;
}
.empty-note {
@apply text-sm leading-6;
color: #526178;
}
.comment-composer-toolbar {
@apply flex items-center justify-end gap-2 border-t pt-3;
border-color: rgba(23, 32, 51, 0.08);
}
.internal-toggle {
@apply cursor-pointer;
}
.internal-toggle input {
@apply sr-only;
}
.comment-tool-actions {
@apply flex min-w-0 items-center justify-end gap-2;
}
.icon-tool-button,
.post-button {
@apply inline-flex min-h-10 items-center justify-center gap-2 rounded-full px-3 text-sm font-bold transition;
}
.icon-tool-button {
@apply w-10;
background: rgba(23, 32, 51, 0.06);
color: #526178;
}
.icon-tool-button:hover,
.icon-tool-button.active {
background: rgba(15, 118, 110, 0.12);
color: #0f766e;
}
.post-button {
@apply px-4;
background: #172033;
color: #fffaf2;
}
.post-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
</style>

View File

@@ -0,0 +1,386 @@
<script setup>
import { computed, ref } from 'vue';
import {
mdiCheckCircleOutline,
mdiDeleteOutline,
mdiDotsVertical,
mdiEmoticonPlusOutline,
mdiPencilOutline,
mdiReplyOutline,
} from '@mdi/js';
import AppAvatar from '@/components/AppAvatar.vue';
import ContentCommentComposer from '@/features/content/components/ContentCommentComposer.vue';
const props = defineProps({
comments: {
type: Array,
default: () => [],
},
members: {
type: Array,
default: () => [],
},
isPosting: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['submit-comment']);
const activeReplyCommentId = ref(null);
const commentThreads = computed(() => {
const repliesByParentId = new Map();
const roots = [];
for (const comment of props.comments) {
if (comment.parentCommentId) {
const existing = repliesByParentId.get(comment.parentCommentId) ?? [];
existing.push(comment);
repliesByParentId.set(comment.parentCommentId, existing);
} else {
roots.push(comment);
}
}
return roots.map(comment => ({
comment,
replies: repliesByParentId.get(comment.id) ?? [],
}));
});
function formatDateTime(value) {
return value ? new Date(value).toLocaleString() : '';
}
function hasImageAttachment(comment) {
return Boolean(comment.attachmentBlobUrl && comment.attachmentContentType?.startsWith('image/'));
}
function submitReply(payload) {
emit('submit-comment', payload);
activeReplyCommentId.value = null;
}
</script>
<template>
<div class="timeline-list">
<article
v-for="thread in commentThreads"
:key="thread.comment.id"
class="comment-row"
tabindex="0"
>
<div class="comment-row-header">
<div class="comment-author">
<AppAvatar
:name="thread.comment.authorDisplayName"
:email="thread.comment.authorEmail"
:src="thread.comment.authorPortraitUrl"
size="sm"
/>
<div class="comment-author-meta">
<strong>{{ thread.comment.authorDisplayName }}</strong>
<small>{{ formatDateTime(thread.comment.createdAt) }}</small>
</div>
</div>
<div class="comment-actions">
<button
class="comment-action-button"
type="button"
title="Add reaction"
>
<v-icon :icon="mdiEmoticonPlusOutline" />
</button>
<button
class="comment-action-button"
type="button"
title="Resolve"
>
<v-icon :icon="mdiCheckCircleOutline" />
</button>
<button
class="comment-action-button"
type="button"
title="Reply"
@click="activeReplyCommentId = thread.comment.id"
>
<v-icon :icon="mdiReplyOutline" />
</button>
<details class="comment-more-menu">
<summary
class="comment-action-button"
title="More comment actions"
>
<v-icon :icon="mdiDotsVertical" />
</summary>
<div class="comment-action-menu">
<button
class="comment-menu-item"
type="button"
>
<v-icon :icon="mdiPencilOutline" />
Edit
</button>
<button
class="comment-menu-item danger"
type="button"
>
<v-icon :icon="mdiDeleteOutline" />
Delete
</button>
</div>
</details>
</div>
</div>
<p
v-if="thread.comment.body"
class="comment-body"
>
{{ thread.comment.body }}
</p>
<a
v-if="hasImageAttachment(thread.comment)"
class="comment-attachment"
:href="thread.comment.attachmentBlobUrl"
target="_blank"
rel="noreferrer"
>
<img
:src="thread.comment.attachmentBlobUrl"
:alt="thread.comment.attachmentFileName || 'Comment attachment'"
/>
</a>
<div
v-if="thread.replies.length"
class="reply-list"
>
<article
v-for="reply in thread.replies"
:key="reply.id"
class="reply-row"
>
<AppAvatar
:name="reply.authorDisplayName"
:email="reply.authorEmail"
:src="reply.authorPortraitUrl"
size="sm"
/>
<div>
<div class="reply-meta">
<strong>{{ reply.authorDisplayName }}</strong>
<small>{{ formatDateTime(reply.createdAt) }}</small>
</div>
<p v-if="reply.body">{{ reply.body }}</p>
<a
v-if="hasImageAttachment(reply)"
class="comment-attachment reply-attachment"
:href="reply.attachmentBlobUrl"
target="_blank"
rel="noreferrer"
>
<img
:src="reply.attachmentBlobUrl"
:alt="reply.attachmentFileName || 'Reply attachment'"
/>
</a>
</div>
</article>
</div>
<ContentCommentComposer
v-if="activeReplyCommentId === thread.comment.id"
variant="reply"
:members="members"
:is-posting="isPosting"
:reply-target="thread.comment"
@submit-comment="submitReply"
@cancel-reply="activeReplyCommentId = null"
/>
</article>
<div
v-if="!commentThreads.length"
class="empty-note"
>
No comments yet.
</div>
</div>
</template>
<style scoped>
.timeline-list {
@apply flex flex-col gap-4;
}
.empty-note {
@apply text-sm leading-6;
color: #526178;
}
.comment-row {
@apply relative flex flex-col gap-3 rounded-[1rem] border p-4 transition;
background: #f8fafc;
border-color: rgba(23, 32, 51, 0.08);
outline: none;
}
.comment-row:hover,
.comment-row:focus-within,
.comment-row:focus {
background: #fffdf8;
border-color: rgba(15, 118, 110, 0.24);
box-shadow: 0 16px 34px rgba(23, 32, 51, 0.08);
}
.comment-row-header {
@apply flex min-h-9 w-full items-center;
}
.comment-author {
@apply flex w-full min-w-0 items-center gap-3;
}
.comment-author-meta {
@apply flex w-full min-w-0 flex-col;
}
.comment-author strong {
@apply truncate text-sm;
color: #172033;
}
.comment-author small {
@apply text-xs leading-5;
color: #7c8798;
}
.comment-actions {
@apply absolute right-3 top-3 z-20 flex items-center gap-1 rounded-full border px-1 py-1 opacity-0 shadow-lg transition;
background: rgba(255, 255, 255, 0.92);
border-color: rgba(23, 32, 51, 0.08);
pointer-events: none;
backdrop-filter: blur(10px);
}
.comment-row:hover .comment-actions,
.comment-row:focus-within .comment-actions,
.comment-row:focus .comment-actions {
opacity: 1;
pointer-events: auto;
}
.comment-action-button {
@apply inline-flex h-8 w-8 items-center justify-center rounded-full transition;
color: #526178;
}
.comment-action-button:hover,
.comment-action-button:focus-visible {
background: rgba(15, 118, 110, 0.12);
color: #0f766e;
}
.comment-more-menu {
@apply relative;
}
.comment-more-menu summary {
list-style: none;
}
.comment-more-menu summary::-webkit-details-marker {
display: none;
}
.comment-more-menu[open] .comment-action-button {
background: rgba(15, 118, 110, 0.12);
color: #0f766e;
}
.comment-more-menu[open] .comment-action-menu,
.comment-more-menu:hover .comment-action-menu,
.comment-more-menu:focus-within .comment-action-menu {
display: flex;
}
.comment-action-menu {
@apply absolute right-0 top-[calc(100%+0.375rem)] z-20 hidden min-w-36 flex-col gap-1 rounded-[0.875rem] border p-1 shadow-xl;
background: #ffffff;
border-color: rgba(23, 32, 51, 0.1);
}
.comment-menu-item {
@apply flex items-center gap-2 rounded-[0.625rem] px-3 py-2 text-sm font-semibold transition;
color: #172033;
}
.comment-menu-item:hover,
.comment-menu-item:focus-visible {
background: rgba(15, 118, 110, 0.1);
color: #0f766e;
}
.comment-menu-item.danger {
color: #b91c1c;
}
.comment-menu-item.danger:hover,
.comment-menu-item.danger:focus-visible {
background: rgba(185, 28, 28, 0.1);
color: #b91c1c;
}
.comment-body {
@apply whitespace-pre-line text-sm leading-6;
color: #172033;
}
.comment-attachment {
@apply block w-fit max-w-full overflow-hidden rounded-[0.875rem] border;
border-color: rgba(23, 32, 51, 0.1);
background: #ffffff;
}
.comment-attachment img {
@apply block max-h-72 max-w-full object-contain;
}
.reply-attachment img {
@apply max-h-56;
}
.reply-list {
@apply ml-2 mt-2 flex flex-col gap-4;
}
.reply-row {
@apply flex items-start gap-3;
}
.reply-row > div {
@apply flex min-w-0 flex-col gap-1;
}
.reply-meta {
@apply flex min-w-0 flex-col;
}
.reply-meta strong {
@apply truncate text-sm;
color: #172033;
}
.reply-meta small {
@apply text-xs leading-5;
color: #7c8798;
}
.reply-row p {
@apply whitespace-pre-line text-sm leading-6;
color: #172033;
}
</style>

View File

@@ -128,6 +128,29 @@ export const useCalendarIntegrationsStore = defineStore('calendar-integrations',
}
}
async function updateSource(sourceId, payload) {
if (!sourceId) {
return null;
}
error.value = null;
try {
const response = await client.put(`/api/calendar-integrations/sources/${sourceId}`, payload);
const updatedSource = response.data;
if (updatedSource) {
sources.value = sources.value.map(source =>
source.id === updatedSource.id ? updatedSource : source
);
}
return updatedSource;
} catch (updateError) {
console.error('Failed to update calendar source:', updateError);
error.value = 'Failed to update calendar source.';
throw updateError;
}
}
async function refreshSource(sourceId) {
if (!sourceId) {
return null;
@@ -176,6 +199,7 @@ export const useCalendarIntegrationsStore = defineStore('calendar-integrations',
fetchEvents,
searchCatalog,
createSource,
updateSource,
refreshSource,
toggleSourceVisibility,
};

View File

@@ -129,11 +129,8 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
actions.comment = true;
try {
const response = await client.post('/api/comments', {
...payload,
contentItemId,
workspaceId: currentItemWorkspaceId(),
});
const requestPayload = buildCommentPayload(contentItemId, payload);
const response = await client.post('/api/comments', requestPayload);
if (response.data) {
comments.value = [...comments.value, response.data];
await fetchActivity(contentItemId);
@@ -144,6 +141,22 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
}
}
function buildCommentPayload(contentItemId, payload) {
const workspaceId = currentItemWorkspaceId();
const formData = new FormData();
formData.append('workspaceId', workspaceId);
formData.append('contentItemId', contentItemId);
formData.append('body', payload.body ?? '');
if (payload.parentCommentId) {
formData.append('parentCommentId', payload.parentCommentId);
}
if (payload.mediaFile) {
formData.append('attachment', payload.mediaFile, payload.mediaFile.name || 'comment-attachment');
}
return formData;
}
async function submitDecision(contentItemId, approvalId, payload) {
actions.decision = true;

View File

@@ -4,10 +4,11 @@
import { useRoute, useRouter } from 'vue-router';
import { useSessionStorage } from '@vueuse/core';
import { mdiArrowLeft } from '@mdi/js';
import AppAvatar from '@/components/AppAvatar.vue';
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
import ContentApprovalPanel from '@/features/content/components/ContentApprovalPanel.vue';
import ContentCommentComposer from '@/features/content/components/ContentCommentComposer.vue';
import ContentCommentFeed from '@/features/content/components/ContentCommentFeed.vue';
import { useCalendarIntegrationsStore } from '@/features/content/stores/calendarIntegrationsStore.js';
import { useContentItemDetailStore } from '@/features/content/stores/contentItemDetailStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
@@ -39,10 +40,6 @@
placements: [],
});
const commentForm = reactive({
body: '',
});
const assetForm = reactive({
assetType: 'Image',
displayName: '',
@@ -102,6 +99,11 @@
{ key: 'assets', label: 'Assets', count: detailStore.assets.length },
{ key: 'activity', label: 'Activity', count: detailStore.activity.length },
]);
const workspaceMembers = computed(() =>
contentWorkspaceId.value
? workspaceStore.membersByWorkspace[contentWorkspaceId.value] ?? []
: []
);
const selectedDateKey = computed(() => /^\d{4}-\d{2}-\d{2}$/.test(form.dueDate) ? form.dueDate : '');
const contextAnchorDate = computed(() => selectedDateKey.value ? parseDateKey(selectedDateKey.value) : startOfDay(new Date()));
const calendarFetchRange = computed(() => {
@@ -450,13 +452,12 @@
await detailStore.submitDecision(contentItemId.value, approvalId, payload);
}
async function submitComment() {
if (!contentItemId.value || !commentForm.body.trim()) {
async function submitComment(payload) {
if (!contentItemId.value || !payload?.body?.trim()) {
return;
}
await detailStore.addComment(contentItemId.value, { body: commentForm.body.trim() });
commentForm.body = '';
await detailStore.addComment(contentItemId.value, payload);
}
function inferGoogleDriveFileId(value) {
@@ -674,6 +675,7 @@
await Promise.all([
calendarStore.fetchSources(workspaceId),
calendarStore.fetchEvents({ workspaceId, startDate, endDate }),
workspaceStore.fetchMembers(workspaceId),
]);
},
{ immediate: true }
@@ -1127,50 +1129,18 @@
</div>
<template v-if="activeProductionTab === 'comments'">
<div class="panel-stack">
<label class="field field-wide">
<span>New comment</span>
<textarea v-model="commentForm.body"></textarea>
</label>
<button
class="primary-button"
:disabled="detailStore.actions.comment"
@click="submitComment"
>
{{ detailStore.actions.comment ? 'Posting...' : 'Post comment' }}
</button>
</div>
<ContentCommentComposer
:members="workspaceMembers"
:is-posting="detailStore.actions.comment"
@submit-comment="submitComment"
/>
<div class="timeline-list">
<article
v-for="comment in detailStore.comments"
:key="comment.id"
class="timeline-row"
>
<div class="identity-row align-start">
<AppAvatar
:name="comment.authorDisplayName"
:email="comment.authorEmail"
:src="comment.authorPortraitUrl"
size="sm"
/>
<div>
<strong>{{ comment.authorDisplayName }}</strong>
<span>{{ comment.body }}</span>
</div>
</div>
<div class="timeline-actions">
<small>{{ formatDateTime(comment.createdAt) }}</small>
</div>
</article>
<div
v-if="!detailStore.comments.length"
class="empty-note"
>
No comments yet.
</div>
</div>
<ContentCommentFeed
:comments="detailStore.comments"
:members="workspaceMembers"
:is-posting="detailStore.actions.comment"
@submit-comment="submitComment"
/>
</template>
<template v-else-if="activeProductionTab === 'revisions'">

File diff suppressed because it is too large Load Diff

View File

@@ -35,13 +35,20 @@
.feedback-entry-button {
@apply flex h-12 items-center gap-2 rounded-full border px-4 text-sm font-bold shadow-lg transition-colors;
background: #172033;
border-color: rgba(255, 255, 255, 0.4);
color: #fffaf2;
background: var(--socialize-accent-strong);
border-color: rgba(255, 255, 255, 0.55);
color: #ffffff;
box-shadow: 0 16px 34px var(--socialize-accent-strong-shadow);
}
.feedback-entry-button:hover {
background: #0f766e;
background: color-mix(in srgb, var(--socialize-accent-strong) 82%, var(--socialize-primary));
box-shadow: 0 18px 38px var(--socialize-accent-strong-shadow);
}
.feedback-entry-button:focus-visible {
outline: 3px solid color-mix(in srgb, var(--socialize-accent) 35%, transparent);
outline-offset: 3px;
}
.feedback-entry-button span {

View File

@@ -1,18 +1,78 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import WorkspaceSelector from './WorkspaceSelector.vue';
import {
mdiCalendar,
mdiChevronDown,
mdiCogOutline,
mdiFormatListBulleted,
mdiLogin,
mdiPlus,
mdiTable,
} from '@mdi/js';
const route = useRoute();
const { t } = useI18n();
const authStore = useAuthStore();
const isContentViewMenuOpen = ref(false);
const contentViewActions = computed(() => {
if (route.name !== 'content-items') {
return [];
}
const query = route.query;
const activeView = ['upcoming', 'table'].includes(query.view) ? query.view : 'calendar';
return [
{
key: 'calendar',
label: t('contentItems.views.calendar'),
icon: mdiCalendar,
active: activeView === 'calendar',
route: {
name: 'content-items',
query: {
...query,
view: query.view === 'week' ? 'week' : 'month',
},
},
},
{
key: 'upcoming',
label: t('contentItems.upcoming'),
icon: mdiFormatListBulleted,
active: activeView === 'upcoming',
route: {
name: 'content-items',
query: {
...query,
view: 'upcoming',
},
},
},
{
key: 'table',
label: t('contentItems.views.table'),
icon: mdiTable,
active: activeView === 'table',
route: {
name: 'content-items',
query: {
...query,
view: 'table',
},
},
},
];
});
const activeContentViewAction = computed(() =>
contentViewActions.value.find(action => action.active) ?? contentViewActions.value[0]
);
const appBarActions = computed(() => {
if (!authStore.isAuthenticated) {
@@ -79,6 +139,46 @@
</router-link>
</template>
<div
v-if="contentViewActions.length"
class="view-selector"
>
<button
class="menu-item-action view-selector-button"
type="button"
@click="isContentViewMenuOpen = !isContentViewMenuOpen"
>
<v-icon :icon="activeContentViewAction.icon" />
<span class="label">{{ activeContentViewAction.label }}</span>
<v-icon
class="selector-chevron"
:icon="mdiChevronDown"
/>
</button>
<div
v-if="isContentViewMenuOpen"
class="view-selector-menu"
>
<router-link
v-for="action in contentViewActions"
:key="action.key"
:to="action.route"
class="menu-action-link"
@click="isContentViewMenuOpen = false"
>
<button
class="view-selector-option"
:class="{ 'view-selector-option-active': action.active }"
type="button"
>
<v-icon :icon="action.icon" />
<span>{{ action.label }}</span>
</button>
</router-link>
</div>
</div>
<router-link
v-for="action in appBarActions"
:key="action.key"
@@ -121,6 +221,24 @@
@apply justify-end;
}
.view-selector {
@apply relative;
}
.view-selector-button {
@apply min-w-11 justify-between;
}
.selector-chevron {
@apply text-base;
}
.view-selector-menu {
@apply absolute right-0 top-[calc(100%+0.5rem)] z-30 flex min-w-52 flex-col gap-1 rounded-[1rem] border p-2 shadow-xl;
background: #ffffff;
border-color: rgba(23, 32, 51, 0.1);
}
.label {
@apply hidden text-nowrap md:inline;
}
@@ -137,6 +255,17 @@
color: #fffaf2;
}
.view-selector-option {
@apply flex min-h-11 w-full items-center gap-3 rounded-[0.75rem] px-3 text-left text-sm font-semibold transition;
color: #172033;
}
.view-selector-option:hover,
.view-selector-option-active {
background: #172033;
color: #fffaf2;
}
.menu-item-action i {
@apply text-xl;
}

View File

@@ -665,7 +665,7 @@
.brand-mark {
@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%);
background: var(--socialize-brand-gradient);
color: #fffaf2;
}

View File

@@ -859,6 +859,17 @@
"newItem": "New content item",
"createTitle": "Create content item",
"upcoming": "Upcoming",
"views": {
"calendar": "Calendar view",
"table": "Table"
},
"table": {
"content": "Content",
"status": "Status",
"channels": "Channels",
"revision": "Revision",
"dueDate": "Due date"
},
"loading": "Loading content items...",
"empty": "No content items are available for the active workspace.",
"noDueDate": "No due date",
@@ -879,9 +890,14 @@
"category": "Category",
"calendarName": "Calendar name",
"icsUrl": "ICS URL",
"editColor": "Edit calendar color",
"allDay": "All day",
"context": "Calendar context",
"importedEvent": "Imported calendar",
"previousWeek": "Previous week",
"nextWeek": "Next week",
"previousMonth": "Previous month",
"nextMonth": "Next month",
"errors": {
"required": "Calendar name and URL are required.",
"duplicate": "This calendar has already been added.",

View File

@@ -859,6 +859,17 @@
"newItem": "Nouvel élément de contenu",
"createTitle": "Créer un élément de contenu",
"upcoming": "À venir",
"views": {
"calendar": "Vue calendrier",
"table": "Tableau"
},
"table": {
"content": "Contenu",
"status": "Statut",
"channels": "Canaux",
"revision": "Révision",
"dueDate": "Échéance"
},
"loading": "Chargement des éléments de contenu...",
"empty": "Aucun élément de contenu n'est disponible pour l'espace actif.",
"noDueDate": "Aucune échéance",
@@ -879,9 +890,14 @@
"category": "Catégorie",
"calendarName": "Nom du calendrier",
"icsUrl": "URL ICS",
"editColor": "Modifier la couleur du calendrier",
"allDay": "Toute la journée",
"context": "Contexte calendrier",
"importedEvent": "Calendrier importé",
"previousWeek": "Semaine précédente",
"nextWeek": "Semaine suivante",
"previousMonth": "Mois précédent",
"nextMonth": "Mois suivant",
"errors": {
"required": "Le nom et l'URL du calendrier sont requis.",
"duplicate": "Ce calendrier a déjà été ajouté.",

View File

@@ -278,7 +278,7 @@
.site-brand-mark {
@apply flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-2xl text-base font-black;
background: linear-gradient(135deg, #ff8a3d 0%, #ef4444 100%);
background: var(--socialize-brand-gradient);
color: #fffaf2;
}