2281 lines
80 KiB
Vue
2281 lines
80 KiB
Vue
<script setup>
|
|
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useRoute, useRouter } from 'vue-router';
|
|
import { useSessionStorage } from '@vueuse/core';
|
|
import {
|
|
mdiArrowLeft,
|
|
mdiCheckboxBlankOutline,
|
|
mdiCheckboxMarked,
|
|
mdiClose,
|
|
mdiFacebook,
|
|
mdiInstagram,
|
|
mdiLinkedin,
|
|
mdiMusicNote,
|
|
mdiReddit,
|
|
mdiTwitter,
|
|
mdiWeb,
|
|
mdiYoutube,
|
|
} from '@mdi/js';
|
|
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';
|
|
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
|
|
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const { locale, t } = useI18n();
|
|
const workspaceStore = useWorkspaceStore();
|
|
const campaignsStore = useCampaignsStore();
|
|
const clientsStore = useClientsStore();
|
|
const channelsStore = useChannelsStore();
|
|
const calendarStore = useCalendarIntegrationsStore();
|
|
const contentItemsStore = useContentItemsStore();
|
|
const detailStore = useContentItemDetailStore();
|
|
|
|
const editorDrafts = useSessionStorage('content-editor-drafts', {}, {
|
|
mergeDefaults: true,
|
|
});
|
|
|
|
const form = reactive({
|
|
title: '',
|
|
campaignId: '',
|
|
dueDate: '',
|
|
body: '',
|
|
hashtags: '',
|
|
changeSummary: '',
|
|
placements: [],
|
|
});
|
|
|
|
const assetForm = reactive({
|
|
assetType: 'Image',
|
|
displayName: '',
|
|
googleDriveFileId: '',
|
|
googleDriveLink: '',
|
|
previewUrl: '',
|
|
});
|
|
|
|
const assetRevisionForms = reactive({});
|
|
const activeProductionTab = ref('comments');
|
|
const activeCalendarEvent = ref(null);
|
|
const activePlacementId = ref('');
|
|
const hashtagInput = ref('');
|
|
|
|
const saveError = reactive({
|
|
message: '',
|
|
});
|
|
|
|
const isCreateMode = computed(() => route.name === 'content-item-create');
|
|
const contentItemId = computed(() => isCreateMode.value ? null : route.params.id);
|
|
const item = computed(() => detailStore.item);
|
|
const availableCampaigns = computed(() => campaignsStore.campaigns);
|
|
const contentWorkspaceId = computed(() => item.value?.workspaceId ?? workspaceStore.activeWorkspaceId);
|
|
const availableChannels = computed(() =>
|
|
channelsStore.channels.filter(channel =>
|
|
!contentWorkspaceId.value || channel.workspaceId === contentWorkspaceId.value
|
|
)
|
|
);
|
|
const groupedChannels = computed(() => {
|
|
const groups = new Map();
|
|
|
|
for (const channel of availableChannels.value) {
|
|
const network = channel.network || 'Other';
|
|
const existing = groups.get(network) ?? [];
|
|
existing.push(channel);
|
|
groups.set(network, existing);
|
|
}
|
|
|
|
return Array.from(groups.entries()).map(([network, channels]) => ({
|
|
network,
|
|
channels,
|
|
}));
|
|
});
|
|
const placementSummary = computed(() =>
|
|
uniqueTargets(form.placements
|
|
.map(placement => placement.channelName || placement.network)
|
|
.filter(Boolean))
|
|
.join(', ')
|
|
);
|
|
const selectedPlacements = computed(() =>
|
|
form.placements.filter(placement => placement.channelId || placement.channelName || placement.network)
|
|
);
|
|
const activePlacement = computed(() => {
|
|
if (!selectedPlacements.value.length) {
|
|
return null;
|
|
}
|
|
|
|
return selectedPlacements.value.find(placement => placement.id === activePlacementId.value)
|
|
?? selectedPlacements.value[0];
|
|
});
|
|
const sharedPreviewText = computed(() => form.body.trim() || 'Draft the shared caption once. Every selected channel will preview this same content.');
|
|
const sharedPreviewTags = computed(() => parseHashtags(form.hashtags));
|
|
const workspaceHashtagFeed = computed(() => {
|
|
const selected = new Set(sharedPreviewTags.value.map(tag => normalizeHashtag(tag).toLowerCase()));
|
|
const feed = new Map();
|
|
|
|
for (const contentItem of contentItemsStore.items) {
|
|
if (
|
|
contentWorkspaceId.value &&
|
|
contentItem.workspaceId &&
|
|
contentItem.workspaceId !== contentWorkspaceId.value
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
for (const tag of parseHashtags(contentItem.hashtags)) {
|
|
const normalized = normalizeHashtag(tag);
|
|
const key = normalized.toLowerCase();
|
|
|
|
if (!normalized) {
|
|
continue;
|
|
}
|
|
|
|
const existing = feed.get(key) ?? {
|
|
tag: normalized,
|
|
count: 0,
|
|
isSelected: selected.has(key),
|
|
};
|
|
|
|
existing.count += 1;
|
|
existing.isSelected = selected.has(key);
|
|
feed.set(key, existing);
|
|
}
|
|
}
|
|
|
|
return Array.from(feed.values())
|
|
.sort((left, right) => right.count - left.count || left.tag.localeCompare(right.tag));
|
|
});
|
|
const hashtagSuggestions = computed(() =>
|
|
workspaceHashtagFeed.value
|
|
.filter(entry => !entry.isSelected)
|
|
.slice(0, 12)
|
|
);
|
|
const operationalClient = computed(() => clientsStore.operationalClient);
|
|
const campaignNameById = computed(() =>
|
|
new Map(campaignsStore.campaigns.map(campaign => [campaign.id, campaign.name]))
|
|
);
|
|
const editorKey = computed(() => isCreateMode.value ? `new:${route.query.campaignId ?? 'default'}` : String(route.params.id));
|
|
const approvalMode = computed(() => workspaceStore.activeWorkspace?.approvalMode ?? 'Required');
|
|
const productionTabs = computed(() => [
|
|
{ key: 'comments', label: 'Comments', count: detailStore.comments.length },
|
|
{ key: 'revisions', label: 'Revisions', count: detailStore.revisions.length },
|
|
{ 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(() => {
|
|
const start = startOfMonth(contextAnchorDate.value);
|
|
const end = endOfMonth(contextAnchorDate.value);
|
|
|
|
return {
|
|
startDate: dateKey(addDays(start, -7)),
|
|
endDate: dateKey(addDays(end, 7)),
|
|
};
|
|
});
|
|
const visibleCalendarEventsByDate = computed(() => {
|
|
const groups = new Map();
|
|
|
|
for (const event of calendarStore.visibleEvents) {
|
|
const key = dateKey(event.startDate);
|
|
const existing = groups.get(key) ?? [];
|
|
existing.push(event);
|
|
groups.set(key, existing);
|
|
}
|
|
|
|
return groups;
|
|
});
|
|
const selectedDateCalendarEvents = computed(() =>
|
|
selectedDateKey.value
|
|
? visibleCalendarEventsByDate.value.get(selectedDateKey.value) ?? []
|
|
: []
|
|
);
|
|
const dateContextDays = computed(() => {
|
|
const anchor = contextAnchorDate.value;
|
|
|
|
return Array.from({ length: 7 }, (_, index) => {
|
|
const date = addDays(anchor, index - 3);
|
|
const key = dateKey(date);
|
|
|
|
return {
|
|
key,
|
|
date,
|
|
isSelected: key === selectedDateKey.value,
|
|
events: visibleCalendarEventsByDate.value.get(key) ?? [],
|
|
};
|
|
});
|
|
});
|
|
|
|
function blankPlacement(channel = null) {
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
network: channel?.network ?? '',
|
|
channelId: channel?.id ?? '',
|
|
channelName: channel?.name ?? '',
|
|
variantLabel: '',
|
|
message: form.body,
|
|
hashtags: form.hashtags,
|
|
mediaItems: [],
|
|
};
|
|
}
|
|
|
|
function blankMedia() {
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
mediaType: 'Image',
|
|
label: '',
|
|
url: '',
|
|
};
|
|
}
|
|
|
|
function syncPlacementChannel(placement, value) {
|
|
const channel = availableChannels.value.find(candidate => candidate.id === value);
|
|
placement.channelId = value;
|
|
placement.channelName = channel?.name ?? '';
|
|
placement.network = channel?.network ?? placement.network;
|
|
}
|
|
|
|
function addPlacement(channel = null) {
|
|
if (channel) {
|
|
const existing = form.placements.find(placement => placement.channelId === channel.id);
|
|
|
|
if (existing) {
|
|
activePlacementId.value = existing.id;
|
|
return;
|
|
}
|
|
}
|
|
|
|
const placement = blankPlacement(channel);
|
|
placement.message = form.body;
|
|
placement.hashtags = form.hashtags;
|
|
form.placements.push(placement);
|
|
activePlacementId.value = placement.id;
|
|
}
|
|
|
|
function removePlacement(placementId) {
|
|
form.placements = form.placements.filter(placement => placement.id !== placementId);
|
|
|
|
if (activePlacementId.value === placementId) {
|
|
activePlacementId.value = form.placements[0]?.id ?? '';
|
|
}
|
|
}
|
|
|
|
function toggleChannel(channel) {
|
|
const existing = form.placements.find(placement => placement.channelId === channel.id);
|
|
|
|
if (existing) {
|
|
removePlacement(existing.id);
|
|
return;
|
|
}
|
|
|
|
addPlacement(channel);
|
|
}
|
|
|
|
function isChannelSelected(channelId) {
|
|
return form.placements.some(placement => placement.channelId === channelId);
|
|
}
|
|
|
|
function setActivePlacement(placement) {
|
|
activePlacementId.value = placement.id;
|
|
}
|
|
|
|
function addMedia(placement) {
|
|
placement.mediaItems.push(blankMedia());
|
|
}
|
|
|
|
function removeMedia(placement, mediaId) {
|
|
placement.mediaItems = placement.mediaItems.filter(media => media.id !== mediaId);
|
|
}
|
|
|
|
function removeHashtag(tagToRemove) {
|
|
form.hashtags = parseHashtags(form.hashtags)
|
|
.filter(tag => normalizeHashtag(tag).toLowerCase() !== normalizeHashtag(tagToRemove).toLowerCase())
|
|
.join(' ');
|
|
}
|
|
|
|
function normalizeHashtag(value) {
|
|
const cleaned = (value ?? '')
|
|
.trim()
|
|
.replace(/^#+/, '')
|
|
.replace(/[^\p{L}\p{N}_-]/gu, '');
|
|
|
|
return cleaned ? `#${cleaned}` : '';
|
|
}
|
|
|
|
function addHashtag(value) {
|
|
const normalized = normalizeHashtag(value);
|
|
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
|
|
const tags = parseHashtags(form.hashtags);
|
|
const exists = tags.some(tag => normalizeHashtag(tag).toLowerCase() === normalized.toLowerCase());
|
|
|
|
if (!exists) {
|
|
form.hashtags = [...tags, normalized].join(' ');
|
|
}
|
|
}
|
|
|
|
function commitHashtagInput() {
|
|
const entries = hashtagInput.value
|
|
.split(/[,\s]+/)
|
|
.map(entry => entry.trim())
|
|
.filter(Boolean);
|
|
|
|
for (const entry of entries) {
|
|
addHashtag(entry);
|
|
}
|
|
|
|
hashtagInput.value = '';
|
|
}
|
|
|
|
function handleHashtagKeydown(event) {
|
|
if (event.key === 'Enter' || event.key === ',' || event.key === 'Tab') {
|
|
if (hashtagInput.value.trim()) {
|
|
event.preventDefault();
|
|
commitHashtagInput();
|
|
}
|
|
}
|
|
|
|
if (event.key === 'Backspace' && !hashtagInput.value && sharedPreviewTags.value.length) {
|
|
removeHashtag(sharedPreviewTags.value.at(-1));
|
|
}
|
|
}
|
|
|
|
function handleHashtagPaste(event) {
|
|
const pasted = event.clipboardData?.getData('text');
|
|
|
|
if (!pasted || !/[\s,]/.test(pasted)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
hashtagInput.value = pasted;
|
|
commitHashtagInput();
|
|
}
|
|
|
|
function parseHashtags(value) {
|
|
return (value ?? '')
|
|
.split(/[,\s]+/)
|
|
.map(tag => tag.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function uniqueTargets(targets) {
|
|
const seen = new Set();
|
|
const unique = [];
|
|
|
|
for (const target of targets) {
|
|
const normalized = target.trim();
|
|
const key = normalized.toLowerCase();
|
|
|
|
if (!normalized || seen.has(key)) {
|
|
continue;
|
|
}
|
|
|
|
seen.add(key);
|
|
unique.push(normalized);
|
|
}
|
|
|
|
return unique;
|
|
}
|
|
|
|
function parseTargets(value) {
|
|
return uniqueTargets((value ?? '')
|
|
.split(',')
|
|
.map(entry => entry.trim())
|
|
.filter(Boolean));
|
|
}
|
|
|
|
function normalizePlacements(placements) {
|
|
const seen = new Set();
|
|
const normalizedPlacements = [];
|
|
|
|
for (const placement of placements) {
|
|
const targetLabel = (placement.channelName || placement.network || '').trim();
|
|
const knownChannel = channelsStore.channels.find(candidate =>
|
|
candidate.id === placement.channelId ||
|
|
candidate.name.toLowerCase() === targetLabel.toLowerCase()
|
|
);
|
|
const channel = availableChannels.value.find(candidate =>
|
|
candidate.id === placement.channelId ||
|
|
candidate.name.toLowerCase() === targetLabel.toLowerCase()
|
|
);
|
|
|
|
if (knownChannel && !channel) {
|
|
continue;
|
|
}
|
|
|
|
const channelId = channel?.id ?? placement.channelId ?? '';
|
|
const channelName = channel?.name ?? placement.channelName ?? '';
|
|
const network = channel?.network ?? placement.network ?? '';
|
|
const key = (channelName || network || placement.id || crypto.randomUUID()).toLowerCase();
|
|
|
|
if ((channelName || network) && seen.has(key)) {
|
|
continue;
|
|
}
|
|
|
|
seen.add(key);
|
|
normalizedPlacements.push({
|
|
id: placement.id ?? crypto.randomUUID(),
|
|
network,
|
|
channelId,
|
|
channelName,
|
|
variantLabel: placement.variantLabel ?? '',
|
|
message: form.body,
|
|
hashtags: form.hashtags,
|
|
mediaItems: (placement.mediaItems ?? []).map(media => ({
|
|
id: media.id ?? crypto.randomUUID(),
|
|
mediaType: media.mediaType ?? 'Image',
|
|
label: media.label ?? '',
|
|
url: media.url ?? '',
|
|
})),
|
|
});
|
|
}
|
|
|
|
return normalizedPlacements;
|
|
}
|
|
|
|
function serializeDraft() {
|
|
return JSON.parse(JSON.stringify({
|
|
title: form.title,
|
|
campaignId: form.campaignId,
|
|
dueDate: form.dueDate,
|
|
body: form.body,
|
|
hashtags: form.hashtags,
|
|
changeSummary: form.changeSummary,
|
|
placements: form.placements.map(placement => ({
|
|
...placement,
|
|
message: form.body,
|
|
hashtags: form.hashtags,
|
|
})),
|
|
}));
|
|
}
|
|
|
|
function restoreDraft(draft) {
|
|
form.title = draft.title ?? '';
|
|
form.campaignId = draft.campaignId ?? availableCampaigns.value[0]?.id ?? '';
|
|
form.dueDate = draft.dueDate ?? '';
|
|
form.body = draft.body ?? '';
|
|
form.hashtags = draft.hashtags ?? '';
|
|
form.changeSummary = draft.changeSummary ?? '';
|
|
form.placements = normalizePlacements(draft.placements ?? []);
|
|
activePlacementId.value = form.placements[0]?.id ?? '';
|
|
}
|
|
|
|
function buildDraftFromItem() {
|
|
const campaignId = item.value?.campaignId ?? '';
|
|
const placements = parseTargets(item.value?.publicationTargets).map(target => {
|
|
const channel = availableChannels.value.find(candidate => candidate.name.toLowerCase() === target.toLowerCase());
|
|
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
network: channel?.network ?? '',
|
|
channelId: channel?.id ?? '',
|
|
channelName: channel?.name ?? target,
|
|
variantLabel: '',
|
|
message: item.value?.publicationMessage ?? '',
|
|
hashtags: item.value?.hashtags ?? '',
|
|
mediaItems: [],
|
|
};
|
|
});
|
|
|
|
restoreDraft({
|
|
title: item.value?.title ?? '',
|
|
campaignId,
|
|
dueDate: item.value?.dueDate ? new Date(item.value.dueDate).toISOString().slice(0, 10) : '',
|
|
body: item.value?.publicationMessage ?? '',
|
|
hashtags: item.value?.hashtags ?? '',
|
|
changeSummary: '',
|
|
placements: placements.length ? placements : [blankPlacement()],
|
|
});
|
|
}
|
|
|
|
function buildDraftForNew() {
|
|
const campaignIdFromRoute = typeof route.query.campaignId === 'string' ? route.query.campaignId : '';
|
|
const titleFromRoute = typeof route.query.title === 'string' ? route.query.title : '';
|
|
const dateFromRoute = typeof route.query.date === 'string' ? route.query.date : '';
|
|
|
|
restoreDraft({
|
|
title: titleFromRoute,
|
|
campaignId: availableCampaigns.value.some(campaign => campaign.id === campaignIdFromRoute)
|
|
? campaignIdFromRoute
|
|
: availableCampaigns.value[0]?.id ?? '',
|
|
dueDate: /^\d{4}-\d{2}-\d{2}$/.test(dateFromRoute) ? dateFromRoute : '',
|
|
body: titleFromRoute,
|
|
hashtags: '',
|
|
changeSummary: '',
|
|
placements: [],
|
|
});
|
|
}
|
|
|
|
async function hydrateEditor() {
|
|
saveError.message = '';
|
|
|
|
if (isCreateMode.value) {
|
|
const draft = editorDrafts.value[editorKey.value];
|
|
if (draft) {
|
|
restoreDraft(draft);
|
|
} else {
|
|
buildDraftForNew();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!contentItemId.value) {
|
|
return;
|
|
}
|
|
|
|
await detailStore.fetchContentItemDetail(contentItemId.value);
|
|
|
|
const draft = editorDrafts.value[editorKey.value];
|
|
if (draft) {
|
|
restoreDraft(draft);
|
|
return;
|
|
}
|
|
|
|
buildDraftFromItem();
|
|
}
|
|
|
|
function persistDraft() {
|
|
editorDrafts.value = {
|
|
...editorDrafts.value,
|
|
[editorKey.value]: serializeDraft(),
|
|
};
|
|
}
|
|
|
|
function clearDraft(key = editorKey.value) {
|
|
const nextDrafts = { ...editorDrafts.value };
|
|
delete nextDrafts[key];
|
|
editorDrafts.value = nextDrafts;
|
|
}
|
|
|
|
async function saveContent() {
|
|
saveError.message = '';
|
|
|
|
if (!form.title.trim() || !form.campaignId || !form.placements.length) {
|
|
saveError.message = 'Title, campaign, and at least one channel are required.';
|
|
return;
|
|
}
|
|
|
|
if (isCreateMode.value && !operationalClient.value?.id) {
|
|
saveError.message = 'This workspace needs an operational account before content can be created.';
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
title: form.title.trim(),
|
|
campaignId: form.campaignId,
|
|
publicationMessage: form.body.trim(),
|
|
publicationTargets: placementSummary.value,
|
|
hashtags: form.hashtags.trim(),
|
|
dueDate: form.dueDate ? new Date(form.dueDate).toISOString() : null,
|
|
};
|
|
|
|
if (isCreateMode.value) {
|
|
try {
|
|
const created = await contentItemsStore.createContentItem({
|
|
clientId: operationalClient.value.id,
|
|
...payload,
|
|
});
|
|
|
|
editorDrafts.value = {
|
|
...editorDrafts.value,
|
|
[String(created.id)]: serializeDraft(),
|
|
};
|
|
clearDraft();
|
|
await router.replace({ name: 'content-item-detail', params: { id: created.id } });
|
|
} catch (error) {
|
|
saveError.message = 'The content item could not be created.';
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await detailStore.createRevision(contentItemId.value, {
|
|
...payload,
|
|
changeSummary: form.changeSummary.trim() || 'Updated content plan',
|
|
});
|
|
|
|
persistDraft();
|
|
form.changeSummary = '';
|
|
} catch (error) {
|
|
saveError.message = 'The content revision could not be saved.';
|
|
}
|
|
}
|
|
|
|
async function submitDecision(approvalId, payload) {
|
|
if (!contentItemId.value) {
|
|
return;
|
|
}
|
|
|
|
await detailStore.submitDecision(contentItemId.value, approvalId, payload);
|
|
}
|
|
|
|
async function submitComment(payload) {
|
|
if (!contentItemId.value || !payload?.body?.trim()) {
|
|
return;
|
|
}
|
|
|
|
await detailStore.addComment(contentItemId.value, payload);
|
|
}
|
|
|
|
function inferGoogleDriveFileId(value) {
|
|
const trimmed = value.trim();
|
|
const filePathMatch = trimmed.match(/\/d\/([^/]+)/);
|
|
const queryMatch = trimmed.match(/[?&]id=([^&]+)/);
|
|
|
|
return filePathMatch?.[1] ?? queryMatch?.[1] ?? '';
|
|
}
|
|
|
|
function resetAssetForm() {
|
|
assetForm.assetType = 'Image';
|
|
assetForm.displayName = '';
|
|
assetForm.googleDriveFileId = '';
|
|
assetForm.googleDriveLink = '';
|
|
assetForm.previewUrl = '';
|
|
}
|
|
|
|
async function linkGoogleDriveAsset() {
|
|
if (!contentItemId.value || !assetForm.displayName.trim() || !assetForm.googleDriveLink.trim()) {
|
|
return;
|
|
}
|
|
|
|
const googleDriveFileId = assetForm.googleDriveFileId.trim() || inferGoogleDriveFileId(assetForm.googleDriveLink);
|
|
|
|
if (!googleDriveFileId) {
|
|
saveError.message = 'A Google Drive file id is required when it cannot be read from the link.';
|
|
return;
|
|
}
|
|
|
|
await detailStore.addGoogleDriveAsset(contentItemId.value, {
|
|
assetType: assetForm.assetType,
|
|
displayName: assetForm.displayName.trim(),
|
|
googleDriveFileId,
|
|
googleDriveLink: assetForm.googleDriveLink.trim(),
|
|
previewUrl: assetForm.previewUrl.trim() || null,
|
|
});
|
|
resetAssetForm();
|
|
}
|
|
|
|
function assetRevisionForm(assetId) {
|
|
if (!assetRevisionForms[assetId]) {
|
|
assetRevisionForms[assetId] = {
|
|
sourceReference: '',
|
|
previewUrl: '',
|
|
notes: '',
|
|
};
|
|
}
|
|
|
|
return assetRevisionForms[assetId];
|
|
}
|
|
|
|
async function addAssetRevision(asset) {
|
|
const draft = assetRevisionForm(asset.id);
|
|
|
|
if (!contentItemId.value || !draft.sourceReference.trim()) {
|
|
return;
|
|
}
|
|
|
|
await detailStore.addAssetRevision(contentItemId.value, asset.id, {
|
|
sourceReference: draft.sourceReference.trim(),
|
|
previewUrl: draft.previewUrl.trim() || null,
|
|
notes: draft.notes.trim() || null,
|
|
});
|
|
|
|
assetRevisionForms[asset.id] = {
|
|
sourceReference: '',
|
|
previewUrl: '',
|
|
notes: '',
|
|
};
|
|
}
|
|
|
|
async function navigateBackToContent() {
|
|
const returnTo = typeof route.query.returnTo === 'string' ? route.query.returnTo : '';
|
|
const previousPath = router.options.history.state.back;
|
|
|
|
if (returnTo.startsWith('/app/content')) {
|
|
await router.push(returnTo);
|
|
return;
|
|
}
|
|
|
|
if (typeof previousPath === 'string' && previousPath.startsWith('/app/content')) {
|
|
router.back();
|
|
return;
|
|
}
|
|
|
|
await router.push({ name: 'content-items' });
|
|
}
|
|
|
|
function showCalendarEvent(event) {
|
|
activeCalendarEvent.value = {
|
|
...event,
|
|
source: calendarStore.sourceById(event.calendarSourceId),
|
|
};
|
|
}
|
|
|
|
function closeCalendarEvent() {
|
|
activeCalendarEvent.value = null;
|
|
}
|
|
|
|
async function navigateToCalendarDay(event = activeCalendarEvent.value) {
|
|
const targetDate = event?.startDate ? dateKey(event.startDate) : selectedDateKey.value;
|
|
|
|
if (!targetDate) {
|
|
return;
|
|
}
|
|
|
|
await router.push({
|
|
name: 'content-items',
|
|
query: {
|
|
view: 'month',
|
|
date: targetDate,
|
|
},
|
|
});
|
|
}
|
|
|
|
function selectContextDate(key) {
|
|
form.dueDate = key;
|
|
}
|
|
|
|
function calendarEventSource(event) {
|
|
return calendarStore.sourceById(event.calendarSourceId);
|
|
}
|
|
|
|
function calendarEventColor(event) {
|
|
return calendarEventSource(event)?.color ?? 'var(--app-color-on-tertiary)';
|
|
}
|
|
|
|
function formatDateTime(value) {
|
|
return value ? new Date(value).toLocaleString() : '';
|
|
}
|
|
|
|
function formatCalendarDate(value) {
|
|
return value
|
|
? new Intl.DateTimeFormat(locale.value, { month: 'short', day: 'numeric', year: 'numeric' }).format(parseCalendarDate(value))
|
|
: '';
|
|
}
|
|
|
|
function formatContextDay(date) {
|
|
return new Intl.DateTimeFormat(locale.value, { weekday: 'short', day: 'numeric' }).format(date);
|
|
}
|
|
|
|
function channelIcon(network) {
|
|
const normalized = (network ?? '').toLowerCase();
|
|
|
|
if (normalized.includes('youtube')) {
|
|
return mdiYoutube;
|
|
}
|
|
|
|
if (normalized === 'x' || normalized.includes('twitter')) {
|
|
return mdiTwitter;
|
|
}
|
|
|
|
if (normalized.includes('instagram')) {
|
|
return mdiInstagram;
|
|
}
|
|
|
|
if (normalized.includes('facebook')) {
|
|
return mdiFacebook;
|
|
}
|
|
|
|
if (normalized.includes('linkedin')) {
|
|
return mdiLinkedin;
|
|
}
|
|
|
|
if (normalized.includes('reddit')) {
|
|
return mdiReddit;
|
|
}
|
|
|
|
if (normalized.includes('tiktok')) {
|
|
return mdiMusicNote;
|
|
}
|
|
|
|
return mdiWeb;
|
|
}
|
|
|
|
function previewNetworkClass(network) {
|
|
const normalized = (network ?? '').toLowerCase();
|
|
|
|
if (normalized.includes('youtube')) {
|
|
return 'youtube';
|
|
}
|
|
|
|
if (normalized === 'x' || normalized.includes('twitter')) {
|
|
return 'x';
|
|
}
|
|
|
|
if (normalized.includes('instagram') || normalized.includes('tiktok')) {
|
|
return 'vertical';
|
|
}
|
|
|
|
return 'standard';
|
|
}
|
|
|
|
function previewProfileName(placement = activePlacement.value) {
|
|
return placement?.channelName || placement?.network || 'Selected channel';
|
|
}
|
|
|
|
function previewHandle(placement = activePlacement.value) {
|
|
const channel = availableChannels.value.find(candidate => candidate.id === placement?.channelId);
|
|
const handle = channel?.handle || placement?.channelName || placement?.network || 'channel';
|
|
|
|
return handle.startsWith('@') ? handle : `@${handle.replace(/\s+/g, '').toLowerCase()}`;
|
|
}
|
|
|
|
function startOfDay(value) {
|
|
const date = new Date(value);
|
|
date.setHours(0, 0, 0, 0);
|
|
return date;
|
|
}
|
|
|
|
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 parseDateKey(value) {
|
|
const [year, month, day] = value.split('-').map(Number);
|
|
return new Date(year, month - 1, day);
|
|
}
|
|
|
|
function dateKey(value) {
|
|
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
|
return value.slice(0, 10);
|
|
}
|
|
|
|
const date = new Date(value);
|
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
}
|
|
|
|
function parseCalendarDate(value) {
|
|
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
|
return parseDateKey(value.slice(0, 10));
|
|
}
|
|
|
|
return new Date(value);
|
|
}
|
|
|
|
watch(
|
|
() => [
|
|
isCreateMode.value,
|
|
route.params.id,
|
|
route.query.campaignId,
|
|
availableCampaigns.value.length,
|
|
availableChannels.value.length,
|
|
],
|
|
async () => {
|
|
await hydrateEditor();
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
watch(
|
|
() => [
|
|
contentWorkspaceId.value,
|
|
calendarFetchRange.value.startDate,
|
|
calendarFetchRange.value.endDate,
|
|
],
|
|
async ([workspaceId, startDate, endDate]) => {
|
|
if (!workspaceId) {
|
|
return;
|
|
}
|
|
|
|
await Promise.all([
|
|
contentItemsStore.fetchContentItems(),
|
|
calendarStore.fetchSources(workspaceId),
|
|
calendarStore.fetchEvents({ workspaceId, startDate, endDate }),
|
|
workspaceStore.fetchMembers(workspaceId),
|
|
]);
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
watch(
|
|
() => [
|
|
form.title,
|
|
form.campaignId,
|
|
form.dueDate,
|
|
form.body,
|
|
form.hashtags,
|
|
form.changeSummary,
|
|
form.placements,
|
|
],
|
|
() => {
|
|
persistDraft();
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
onBeforeUnmount(() => {
|
|
detailStore.reset();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<section class="editor-shell">
|
|
<v-btn variant="text" :ripple="false"
|
|
class="back-button"
|
|
type="button"
|
|
@click="navigateBackToContent"
|
|
>
|
|
<v-icon :icon="mdiArrowLeft" />
|
|
Back to content
|
|
</v-btn>
|
|
|
|
<div
|
|
v-if="!isCreateMode && detailStore.isLoading"
|
|
class="page-message"
|
|
>
|
|
Loading content item...
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="!isCreateMode && detailStore.error"
|
|
class="page-message error"
|
|
>
|
|
{{ detailStore.error }}
|
|
</div>
|
|
|
|
<template v-else>
|
|
<div class="editor-header">
|
|
<div>
|
|
<div class="eyebrow">{{ isCreateMode ? 'New content' : 'Content item' }}</div>
|
|
<h1>{{ form.title || 'Untitled content' }}</h1>
|
|
<p>
|
|
{{ campaignNameById.get(form.campaignId) || 'Choose a campaign' }}
|
|
<template v-if="!isCreateMode && item">
|
|
· {{ item.status }}
|
|
</template>
|
|
</p>
|
|
</div>
|
|
|
|
<div class="header-actions">
|
|
<div
|
|
v-if="!isCreateMode && item"
|
|
class="status-badges"
|
|
>
|
|
<span class="meta-chip">{{ item.status }}</span>
|
|
<span class="meta-chip">{{ item.currentRevisionLabel }}</span>
|
|
</div>
|
|
|
|
<v-btn variant="text" :ripple="false"
|
|
class="primary-button"
|
|
:disabled="contentItemsStore.isCreating || detailStore.actions.revision"
|
|
@click="saveContent"
|
|
>
|
|
{{ isCreateMode
|
|
? (contentItemsStore.isCreating ? 'Creating...' : 'Create content')
|
|
: (detailStore.actions.revision ? 'Saving...' : 'Save revision') }}
|
|
</v-btn>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="saveError.message"
|
|
class="page-message error"
|
|
>
|
|
{{ saveError.message }}
|
|
</div>
|
|
|
|
<div class="editor-grid">
|
|
<section class="work-panel">
|
|
<ContentApprovalPanel
|
|
:approvals="detailStore.approvals"
|
|
:approval-mode="approvalMode"
|
|
:content-status="item?.status ?? 'Draft'"
|
|
:is-create-mode="isCreateMode"
|
|
:is-submitting-decision="detailStore.actions.decision"
|
|
@submit-decision="submitDecision"
|
|
/>
|
|
|
|
<main class="content-panel">
|
|
<div class="content-section">
|
|
<div class="section-title-row">
|
|
<strong>Content</strong>
|
|
<span>{{ placementSummary || 'No channels selected yet' }}</span>
|
|
</div>
|
|
|
|
<div class="form-grid">
|
|
<v-text-field
|
|
v-model="form.title"
|
|
label="Title"
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
|
|
<v-select
|
|
v-model="form.campaignId"
|
|
:items="availableCampaigns"
|
|
label="Campaign"
|
|
item-title="name"
|
|
item-value="id"
|
|
placeholder="Select a campaign"
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
|
|
<v-text-field
|
|
v-model="form.dueDate"
|
|
label="Due date"
|
|
type="date"
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
|
|
<div class="date-context field-wide">
|
|
<div class="date-context-days">
|
|
<v-btn variant="text" :ripple="false"
|
|
v-for="day in dateContextDays"
|
|
:key="day.key"
|
|
class="date-context-day"
|
|
:class="{
|
|
active: day.isSelected,
|
|
marked: day.events.length,
|
|
}"
|
|
type="button"
|
|
@click="selectContextDate(day.key)"
|
|
>
|
|
<span>{{ formatContextDay(day.date) }}</span>
|
|
<strong>{{ day.events.length }}</strong>
|
|
</v-btn>
|
|
</div>
|
|
|
|
<div
|
|
v-if="selectedDateCalendarEvents.length"
|
|
class="date-context-panel"
|
|
>
|
|
<v-btn variant="text" :ripple="false"
|
|
v-for="event in selectedDateCalendarEvents"
|
|
:key="event.id"
|
|
class="calendar-context-pill"
|
|
:style="{ '--calendar-color': calendarEventColor(event) }"
|
|
type="button"
|
|
@click="showCalendarEvent(event)"
|
|
>
|
|
{{ event.title }}
|
|
</v-btn>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="selectedDateKey"
|
|
class="date-context-empty"
|
|
>
|
|
{{ t('contentItems.dateContext.noEvents') }}
|
|
</div>
|
|
</div>
|
|
|
|
<v-text-field
|
|
v-model="form.changeSummary"
|
|
class="field-wide"
|
|
label="Change summary"
|
|
placeholder="What changed in this revision?"
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
|
|
<v-textarea
|
|
v-model="form.body"
|
|
class="field-wide"
|
|
label="Shared brief / base caption"
|
|
rows="4"
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
|
|
<label class="field field-wide">
|
|
<span>Shared hashtags</span>
|
|
<div class="hashtags-input-shell">
|
|
<div
|
|
v-if="sharedPreviewTags.length"
|
|
class="hashtags-editor"
|
|
>
|
|
<v-chip
|
|
v-for="tag in sharedPreviewTags"
|
|
:key="`form-${tag}`"
|
|
class="hashtag-chip"
|
|
size="small"
|
|
rounded="pill"
|
|
variant="tonal"
|
|
color="deep-orange"
|
|
closable
|
|
@click:close="removeHashtag(tag)"
|
|
>
|
|
{{ tag.startsWith('#') ? tag : `#${tag}` }}
|
|
</v-chip>
|
|
</div>
|
|
<input
|
|
v-model="hashtagInput"
|
|
class="hashtags-inline-input"
|
|
type="text"
|
|
placeholder="Add hashtag"
|
|
@keydown="handleHashtagKeydown"
|
|
@paste="handleHashtagPaste"
|
|
@blur="commitHashtagInput"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
v-if="workspaceHashtagFeed.length"
|
|
class="hashtag-feed"
|
|
>
|
|
<div class="hashtag-feed-heading">
|
|
<strong>Workspace hashtag feed</strong>
|
|
<span>{{ workspaceHashtagFeed.length }} used</span>
|
|
</div>
|
|
|
|
<div class="hashtag-suggestions">
|
|
<v-btn variant="text" :ripple="false"
|
|
v-for="entry in hashtagSuggestions"
|
|
:key="`suggestion-${entry.tag}`"
|
|
class="hashtag-suggestion"
|
|
type="button"
|
|
@click="addHashtag(entry.tag)"
|
|
>
|
|
<span>{{ entry.tag }}</span>
|
|
<small>{{ entry.count }}</small>
|
|
</v-btn>
|
|
</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content-section preview-editor-section">
|
|
<div class="section-title-row">
|
|
<strong>Target channels</strong>
|
|
<span>{{ selectedPlacements.length }} selected</span>
|
|
</div>
|
|
|
|
<div class="preview-editor">
|
|
<aside class="target-rail">
|
|
<template v-if="groupedChannels.length">
|
|
<div
|
|
v-for="group in groupedChannels"
|
|
:key="group.network"
|
|
class="target-group"
|
|
>
|
|
<span>{{ group.network }}</span>
|
|
|
|
<v-btn variant="text" :ripple="false"
|
|
v-for="channel in group.channels"
|
|
:key="channel.id"
|
|
class="target-channel"
|
|
:class="{
|
|
selected: isChannelSelected(channel.id),
|
|
active: activePlacement?.channelId === channel.id,
|
|
}"
|
|
type="button"
|
|
@click="toggleChannel(channel)"
|
|
>
|
|
<v-icon
|
|
class="target-check"
|
|
:icon="isChannelSelected(channel.id) ? mdiCheckboxMarked : mdiCheckboxBlankOutline"
|
|
/>
|
|
<v-icon
|
|
class="target-network"
|
|
:icon="channelIcon(channel.network)"
|
|
/>
|
|
<span>
|
|
<strong>{{ channel.name }}</strong>
|
|
<small>{{ channel.handle || channel.network }}</small>
|
|
</span>
|
|
</v-btn>
|
|
</div>
|
|
</template>
|
|
|
|
<div
|
|
v-else
|
|
class="empty-note"
|
|
>
|
|
Add workspace channels before choosing publication targets.
|
|
</div>
|
|
</aside>
|
|
|
|
<div class="preview-stage">
|
|
<div
|
|
v-if="selectedPlacements.length"
|
|
class="selected-preview-tabs"
|
|
>
|
|
<v-btn variant="text" :ripple="false"
|
|
v-for="placement in selectedPlacements"
|
|
:key="placement.id"
|
|
class="selected-preview-tab"
|
|
:class="{ active: activePlacement?.id === placement.id }"
|
|
type="button"
|
|
@click="setActivePlacement(placement)"
|
|
>
|
|
<v-icon :icon="channelIcon(placement.network)" />
|
|
<span>{{ placement.channelName || placement.network }}</span>
|
|
</v-btn>
|
|
</div>
|
|
|
|
<article
|
|
v-if="activePlacement"
|
|
class="social-preview-card"
|
|
:class="`is-${previewNetworkClass(activePlacement.network)}`"
|
|
>
|
|
<div class="preview-topbar">
|
|
<div class="preview-profile">
|
|
<div class="preview-avatar">
|
|
<v-icon :icon="channelIcon(activePlacement.network)" />
|
|
</div>
|
|
<div>
|
|
<strong>{{ previewProfileName(activePlacement) }}</strong>
|
|
<span>{{ previewHandle(activePlacement) }}</span>
|
|
</div>
|
|
</div>
|
|
<v-btn variant="text" :ripple="false"
|
|
class="preview-remove"
|
|
type="button"
|
|
:aria-label="`Remove ${previewProfileName(activePlacement)}`"
|
|
@click="removePlacement(activePlacement.id)"
|
|
>
|
|
<v-icon :icon="mdiClose" />
|
|
</v-btn>
|
|
</div>
|
|
|
|
<div
|
|
v-if="previewNetworkClass(activePlacement.network) === 'youtube'"
|
|
class="youtube-preview"
|
|
>
|
|
<div class="youtube-player">
|
|
<v-icon :icon="mdiYoutube" />
|
|
</div>
|
|
<div class="youtube-meta">
|
|
<strong>{{ form.title || 'Untitled video' }}</strong>
|
|
<span>{{ previewProfileName(activePlacement) }}</span>
|
|
<p>{{ sharedPreviewText }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="previewNetworkClass(activePlacement.network) === 'x'"
|
|
class="x-preview"
|
|
>
|
|
<p>{{ sharedPreviewText }}</p>
|
|
<div
|
|
v-if="sharedPreviewTags.length"
|
|
class="preview-tags"
|
|
>
|
|
<span
|
|
v-for="tag in sharedPreviewTags"
|
|
:key="`x-${tag}`"
|
|
>
|
|
{{ tag.startsWith('#') ? tag : `#${tag}` }}
|
|
</span>
|
|
</div>
|
|
<div class="x-engagement">
|
|
<span>Reply</span>
|
|
<span>Repost</span>
|
|
<span>Like</span>
|
|
<span>Share</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-else
|
|
class="feed-preview"
|
|
>
|
|
<div class="feed-media">
|
|
<v-icon :icon="channelIcon(activePlacement.network)" />
|
|
</div>
|
|
<p>{{ sharedPreviewText }}</p>
|
|
<div
|
|
v-if="sharedPreviewTags.length"
|
|
class="preview-tags"
|
|
>
|
|
<span
|
|
v-for="tag in sharedPreviewTags"
|
|
:key="`feed-${tag}`"
|
|
>
|
|
{{ tag.startsWith('#') ? tag : `#${tag}` }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
<div
|
|
v-else
|
|
class="preview-empty-state"
|
|
>
|
|
<strong>Select one or more channels</strong>
|
|
<span>The shared content will preview here for every selected target.</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</section>
|
|
|
|
<aside class="panel side-panel">
|
|
<div class="panel-heading">
|
|
<strong>Production</strong>
|
|
<span v-if="!isCreateMode">{{ item?.currentRevisionLabel ?? 'Draft' }}</span>
|
|
</div>
|
|
|
|
<div
|
|
v-if="isCreateMode"
|
|
class="empty-note"
|
|
>
|
|
Save the content first to start comments, revisions, assets, and activity.
|
|
</div>
|
|
|
|
<template v-else>
|
|
<div class="tab-strip">
|
|
<v-btn variant="text" :ripple="false"
|
|
v-for="tab in productionTabs"
|
|
:key="tab.key"
|
|
class="tab-button"
|
|
:class="{ active: activeProductionTab === tab.key }"
|
|
type="button"
|
|
@click="activeProductionTab = tab.key"
|
|
>
|
|
{{ tab.label }}
|
|
<span>{{ tab.count }}</span>
|
|
</v-btn>
|
|
</div>
|
|
|
|
<template v-if="activeProductionTab === 'comments'">
|
|
<ContentCommentComposer
|
|
:members="workspaceMembers"
|
|
:is-posting="detailStore.actions.comment"
|
|
@submit-comment="submitComment"
|
|
/>
|
|
|
|
<ContentCommentFeed
|
|
:comments="detailStore.comments"
|
|
:members="workspaceMembers"
|
|
:is-posting="detailStore.actions.comment"
|
|
@submit-comment="submitComment"
|
|
/>
|
|
</template>
|
|
|
|
<template v-else-if="activeProductionTab === 'revisions'">
|
|
<div class="timeline-list">
|
|
<article
|
|
v-for="revision in detailStore.revisions"
|
|
:key="revision.id"
|
|
class="timeline-row"
|
|
>
|
|
<div class="timeline-row-header">
|
|
<strong>{{ revision.revisionLabel }}</strong>
|
|
<small>{{ formatDateTime(revision.createdAt) }}</small>
|
|
</div>
|
|
<span>{{ revision.title }}</span>
|
|
<p v-if="revision.changeSummary">{{ revision.changeSummary }}</p>
|
|
<small>{{ revision.publicationTargets }}</small>
|
|
</article>
|
|
|
|
<div
|
|
v-if="!detailStore.revisions.length"
|
|
class="empty-note"
|
|
>
|
|
No revisions have been saved yet.
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="activeProductionTab === 'assets'">
|
|
<div class="panel-stack asset-form">
|
|
<v-select
|
|
v-model="assetForm.assetType"
|
|
:items="['Image', 'Video', 'Document', 'Other']"
|
|
label="Type"
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
<v-text-field
|
|
v-model="assetForm.displayName"
|
|
label="Name"
|
|
placeholder="Final reel, cover image..."
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
<v-text-field
|
|
v-model="assetForm.googleDriveLink"
|
|
class="field-wide"
|
|
label="Google Drive link"
|
|
type="url"
|
|
placeholder="https://drive.google.com/..."
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
<v-text-field
|
|
v-model="assetForm.googleDriveFileId"
|
|
label="File id"
|
|
placeholder="Optional if link includes it"
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
<v-text-field
|
|
v-model="assetForm.previewUrl"
|
|
label="Preview URL"
|
|
type="url"
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
<v-btn variant="text" :ripple="false"
|
|
class="primary-button field-wide"
|
|
:disabled="detailStore.actions.asset"
|
|
@click="linkGoogleDriveAsset"
|
|
>
|
|
{{ detailStore.actions.asset ? 'Linking...' : 'Link asset' }}
|
|
</v-btn>
|
|
</div>
|
|
|
|
<div class="timeline-list">
|
|
<article
|
|
v-for="asset in detailStore.assets"
|
|
:key="asset.id"
|
|
class="asset-card"
|
|
>
|
|
<div class="timeline-row-header">
|
|
<div>
|
|
<strong>{{ asset.displayName }}</strong>
|
|
<span>{{ asset.assetType }} · {{ asset.sourceType }}</span>
|
|
</div>
|
|
<span class="revision-pill">v{{ asset.currentRevisionNumber }}</span>
|
|
</div>
|
|
|
|
<a
|
|
class="asset-link"
|
|
:href="asset.googleDriveLink"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
Open source
|
|
</a>
|
|
|
|
<div class="asset-revisions">
|
|
<div
|
|
v-for="revision in asset.revisions"
|
|
:key="revision.id"
|
|
class="asset-revision-row"
|
|
>
|
|
<span>v{{ revision.revisionNumber }}</span>
|
|
<small>{{ formatDateTime(revision.createdAt) }}</small>
|
|
<p v-if="revision.notes">{{ revision.notes }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel-stack compact-form">
|
|
<v-text-field
|
|
v-model="assetRevisionForm(asset.id).sourceReference"
|
|
class="field-wide"
|
|
label="New revision reference"
|
|
type="url"
|
|
placeholder="Updated Drive link or production reference"
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
<v-text-field
|
|
v-model="assetRevisionForm(asset.id).notes"
|
|
class="field-wide"
|
|
label="Notes"
|
|
placeholder="What changed?"
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
<v-btn variant="text" :ripple="false"
|
|
class="secondary-button"
|
|
:disabled="detailStore.actions.assetRevision"
|
|
@click="addAssetRevision(asset)"
|
|
>
|
|
{{ detailStore.actions.assetRevision ? 'Adding...' : 'Add revision' }}
|
|
</v-btn>
|
|
</div>
|
|
</article>
|
|
|
|
<div
|
|
v-if="!detailStore.assets.length"
|
|
class="empty-note"
|
|
>
|
|
No production assets have been linked yet.
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<div class="timeline-list">
|
|
<article
|
|
v-for="entry in detailStore.activity"
|
|
:key="entry.id"
|
|
class="timeline-row"
|
|
>
|
|
<div class="timeline-row-header">
|
|
<strong>{{ entry.eventType }}</strong>
|
|
<small>{{ formatDateTime(entry.createdAt) }}</small>
|
|
</div>
|
|
<span>{{ entry.summary }}</span>
|
|
</article>
|
|
|
|
<div
|
|
v-if="!detailStore.activity.length"
|
|
class="empty-note"
|
|
>
|
|
No activity has been recorded yet.
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
</aside>
|
|
</div>
|
|
|
|
<div
|
|
v-if="activeCalendarEvent"
|
|
class="calendar-event-popover"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
>
|
|
<div class="calendar-event-card">
|
|
<div class="calendar-event-header">
|
|
<div>
|
|
<span>{{ activeCalendarEvent.source?.displayTitle ?? t('contentItems.calendar.importedEvent') }}</span>
|
|
<strong>{{ activeCalendarEvent.title }}</strong>
|
|
</div>
|
|
<v-btn variant="text" :ripple="false"
|
|
class="link-button"
|
|
type="button"
|
|
@click="closeCalendarEvent"
|
|
>
|
|
{{ t('close') }}
|
|
</v-btn>
|
|
</div>
|
|
|
|
<p>{{ formatCalendarDate(activeCalendarEvent.startDate) }}</p>
|
|
<p v-if="activeCalendarEvent.description">{{ activeCalendarEvent.description }}</p>
|
|
<p v-if="activeCalendarEvent.location">{{ activeCalendarEvent.location }}</p>
|
|
|
|
<div class="calendar-event-actions">
|
|
<v-btn variant="text" :ripple="false"
|
|
class="secondary-button"
|
|
type="button"
|
|
@click="navigateToCalendarDay(activeCalendarEvent)"
|
|
>
|
|
{{ t('contentItems.dateContext.viewDay') }}
|
|
</v-btn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
@reference "@/assets/main.css";
|
|
.editor-shell {
|
|
@apply mx-auto flex w-full max-w-[110rem] flex-col gap-5 px-5 py-8 md:px-8;
|
|
}
|
|
|
|
.page-message {
|
|
@apply rounded-[1.25rem] border p-4 text-sm;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
border-color: var(--app-border-subtle);
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.page-message.error {
|
|
color: var(--app-danger-muted);
|
|
}
|
|
|
|
.editor-header {
|
|
@apply flex flex-col gap-4 rounded-[1.75rem] border p-6 lg:flex-row lg:items-start lg:justify-between;
|
|
background: rgba(255, 255, 255, 0.92);
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.eyebrow {
|
|
@apply text-xs font-bold uppercase tracking-[0.24em];
|
|
color: var(--app-color-on-tertiary);
|
|
}
|
|
|
|
.editor-header h1 {
|
|
@apply mt-2 text-4xl font-black;
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.editor-header p,
|
|
.panel-heading span,
|
|
.timeline-row span,
|
|
.timeline-row small,
|
|
.empty-note,
|
|
.placement-header span,
|
|
.section-title-row span {
|
|
@apply text-sm leading-6;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.header-actions,
|
|
.status-badges {
|
|
@apply flex flex-wrap items-center gap-3;
|
|
}
|
|
|
|
.meta-chip {
|
|
@apply rounded-full px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
|
|
background: var(--app-border-subtle);
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.back-button,
|
|
.primary-button,
|
|
.secondary-button {
|
|
@apply inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-bold transition;
|
|
}
|
|
|
|
.back-button {
|
|
@apply w-fit border;
|
|
background: rgba(255, 255, 255, 0.88);
|
|
border-color: var(--app-border-subtle);
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.back-button:hover {
|
|
background: var(--app-color-on-surface);
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.primary-button {
|
|
background: var(--app-color-on-surface);
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.secondary-button {
|
|
background: var(--app-control-hover);
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.editor-grid {
|
|
@apply grid gap-4 xl:grid-cols-[minmax(0,1fr)_22rem];
|
|
}
|
|
|
|
.work-panel {
|
|
@apply grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] items-start gap-4 rounded-[1.75rem] border p-5;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.panel {
|
|
@apply flex min-h-0 flex-col gap-5 rounded-[1.75rem] border p-5;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.side-panel {
|
|
@apply xl:sticky xl:top-28 xl:max-h-[calc(100vh-9rem)] xl:overflow-y-auto;
|
|
}
|
|
|
|
.content-panel {
|
|
@apply flex min-h-0 flex-col gap-6;
|
|
}
|
|
|
|
.panel-heading,
|
|
.section-title-row,
|
|
.placement-header {
|
|
@apply flex items-start justify-between gap-3;
|
|
}
|
|
|
|
.panel-heading strong,
|
|
.section-title-row strong,
|
|
.placement-header strong,
|
|
.timeline-row strong {
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.panel-stack,
|
|
.content-section,
|
|
.placement-list,
|
|
.media-section,
|
|
.media-list,
|
|
.card-stack,
|
|
.timeline-list {
|
|
@apply flex flex-col gap-4;
|
|
}
|
|
|
|
.form-grid {
|
|
@apply grid gap-4 md:grid-cols-2;
|
|
}
|
|
|
|
.compact-grid {
|
|
@apply md:grid-cols-2;
|
|
}
|
|
|
|
.field {
|
|
@apply flex flex-col gap-2;
|
|
}
|
|
|
|
.field-wide {
|
|
@apply md:col-span-2;
|
|
}
|
|
|
|
.field span {
|
|
@apply text-sm font-semibold;
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.field input,
|
|
.field select,
|
|
.field textarea {
|
|
@apply rounded-[1rem] border px-4 py-3 text-sm;
|
|
background: var(--app-color-surface);
|
|
border-color: var(--app-border-subtle);
|
|
color: var(--app-color-on-surface);
|
|
outline: none;
|
|
}
|
|
|
|
.field textarea {
|
|
min-height: 7rem;
|
|
resize: vertical;
|
|
}
|
|
|
|
.date-context {
|
|
@apply flex flex-col gap-3 rounded-[1rem] border p-3;
|
|
background: rgba(15, 118, 110, 0.04);
|
|
border-color: rgba(15, 118, 110, 0.14);
|
|
}
|
|
|
|
.date-context-days {
|
|
@apply grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-7;
|
|
}
|
|
|
|
.date-context-day {
|
|
@apply flex min-h-14 flex-col items-start justify-between rounded-[0.875rem] border px-3 py-2 text-left transition;
|
|
background: rgba(255, 253, 248, 0.9);
|
|
border-color: var(--app-border-subtle);
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.date-context-day span {
|
|
@apply text-xs font-bold uppercase tracking-[0.12em];
|
|
}
|
|
|
|
.date-context-day strong {
|
|
@apply h-5 min-w-5 rounded-full px-1.5 text-center text-xs leading-5;
|
|
background: var(--app-border-subtle);
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.date-context-day.marked {
|
|
border-color: rgba(15, 118, 110, 0.32);
|
|
color: var(--app-color-on-tertiary);
|
|
}
|
|
|
|
.date-context-day.marked strong,
|
|
.date-context-day.active strong {
|
|
background: var(--app-color-on-tertiary);
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.date-context-day.active {
|
|
background: var(--app-color-on-surface);
|
|
border-color: var(--app-color-on-surface);
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.date-context-panel {
|
|
@apply flex flex-wrap gap-2;
|
|
}
|
|
|
|
.calendar-context-pill {
|
|
@apply rounded-full border px-3 py-1.5 text-xs font-bold transition;
|
|
background: color-mix(in srgb, var(--calendar-color) 12%, white);
|
|
border-color: color-mix(in srgb, var(--calendar-color) 34%, white);
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.calendar-context-pill:hover {
|
|
background: var(--calendar-color);
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.date-context-empty {
|
|
@apply text-sm leading-6;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.hashtags-input-shell {
|
|
@apply flex min-h-14 flex-wrap items-center gap-2 rounded-[1rem] border px-4 py-3;
|
|
background: var(--app-color-surface);
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.hashtags-editor {
|
|
@apply flex flex-wrap gap-2;
|
|
}
|
|
|
|
.hashtags-inline-input {
|
|
@apply min-w-[10rem] flex-1 border-0 bg-transparent p-0 text-sm;
|
|
color: var(--app-color-on-surface);
|
|
outline: none;
|
|
}
|
|
|
|
.hashtags-inline-input::placeholder {
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.hashtag-chip {
|
|
@apply font-bold;
|
|
}
|
|
|
|
.hashtag-feed {
|
|
@apply flex flex-col gap-2 rounded-[1rem] border p-3;
|
|
background: rgba(15, 118, 110, 0.04);
|
|
border-color: rgba(15, 118, 110, 0.12);
|
|
}
|
|
|
|
.hashtag-feed-heading {
|
|
@apply flex items-center justify-between gap-3;
|
|
}
|
|
|
|
.hashtag-feed-heading strong {
|
|
@apply text-xs font-bold uppercase tracking-[0.16em];
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.hashtag-feed-heading span {
|
|
@apply text-xs font-semibold;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.hashtag-suggestions {
|
|
@apply flex flex-wrap gap-2;
|
|
}
|
|
|
|
.hashtag-suggestion {
|
|
@apply inline-flex min-h-8 items-center gap-2 rounded-full border px-3 py-1 text-xs font-bold;
|
|
background: rgba(15, 118, 110, 0.06);
|
|
border-color: rgba(15, 118, 110, 0.14);
|
|
color: var(--app-color-on-tertiary);
|
|
}
|
|
|
|
.hashtag-suggestion small {
|
|
@apply rounded-full px-1.5 py-0.5 text-[0.65rem] font-black leading-none;
|
|
background: rgba(15, 118, 110, 0.12);
|
|
color: var(--app-color-on-tertiary);
|
|
}
|
|
|
|
.hashtag-suggestion:hover {
|
|
background: var(--app-color-on-tertiary);
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.hashtag-suggestion:hover small {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.preview-editor-section {
|
|
@apply rounded-[1.25rem] border p-4;
|
|
background: #f8fafc;
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.preview-editor {
|
|
@apply grid gap-4 lg:grid-cols-[18rem_minmax(0,1fr)];
|
|
}
|
|
|
|
.target-rail {
|
|
@apply flex min-w-0 flex-col gap-4 rounded-[1rem] border p-3;
|
|
background: var(--app-color-on-primary);
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.target-group {
|
|
@apply flex flex-col gap-2;
|
|
}
|
|
|
|
.target-group > span {
|
|
@apply px-2 text-xs font-bold uppercase tracking-[0.16em];
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.target-channel {
|
|
@apply grid min-h-16 w-full grid-cols-[1.25rem_2rem_minmax(0,1fr)] items-center gap-3 rounded-[0.875rem] border px-3 py-2 text-left transition;
|
|
background: var(--app-control-subtle);
|
|
border-color: transparent;
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.target-channel:hover,
|
|
.target-channel.selected {
|
|
background: rgba(15, 118, 110, 0.08);
|
|
border-color: rgba(15, 118, 110, 0.2);
|
|
}
|
|
|
|
.target-channel.active {
|
|
background: var(--app-color-on-surface);
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.target-check {
|
|
@apply text-lg;
|
|
color: var(--app-color-on-tertiary);
|
|
}
|
|
|
|
.target-channel.active .target-check {
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.target-network,
|
|
.preview-avatar {
|
|
@apply grid h-8 w-8 place-items-center rounded-full;
|
|
background: rgba(15, 118, 110, 0.12);
|
|
color: var(--app-color-on-tertiary);
|
|
}
|
|
|
|
.target-channel.active .target-network {
|
|
background: rgba(255, 255, 255, 0.18);
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.target-channel span {
|
|
@apply flex min-w-0 flex-col gap-0.5;
|
|
}
|
|
|
|
.target-channel strong,
|
|
.target-channel small {
|
|
@apply block truncate;
|
|
}
|
|
|
|
.target-channel strong {
|
|
@apply text-sm font-bold;
|
|
}
|
|
|
|
.target-channel small {
|
|
@apply text-xs;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.target-channel.active small {
|
|
color: rgba(255, 255, 255, 0.72);
|
|
}
|
|
|
|
.preview-stage {
|
|
@apply flex min-w-0 flex-col gap-3;
|
|
}
|
|
|
|
.selected-preview-tabs {
|
|
@apply flex gap-2 overflow-x-auto pb-1;
|
|
}
|
|
|
|
.selected-preview-tab {
|
|
@apply inline-flex min-h-10 shrink-0 items-center gap-2 rounded-full border px-3 py-2 text-sm font-bold transition;
|
|
background: var(--app-color-on-primary);
|
|
border-color: var(--app-border-subtle);
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.selected-preview-tab.active {
|
|
background: var(--app-color-on-surface);
|
|
border-color: var(--app-color-on-surface);
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.social-preview-card {
|
|
@apply flex min-h-[28rem] flex-col gap-4 rounded-[1.25rem] border p-4 shadow-sm;
|
|
background: var(--app-color-on-primary);
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.preview-topbar,
|
|
.preview-profile {
|
|
@apply flex items-center gap-3;
|
|
}
|
|
|
|
.preview-topbar {
|
|
@apply justify-between;
|
|
}
|
|
|
|
.preview-profile div:last-child {
|
|
@apply flex min-w-0 flex-col;
|
|
}
|
|
|
|
.preview-profile strong {
|
|
@apply truncate text-sm font-black;
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.preview-profile span,
|
|
.youtube-meta span,
|
|
.x-engagement span {
|
|
@apply text-xs;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.preview-remove {
|
|
@apply grid h-9 w-9 place-items-center rounded-full p-0;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.youtube-preview,
|
|
.feed-preview,
|
|
.x-preview {
|
|
@apply flex flex-1 flex-col gap-4;
|
|
}
|
|
|
|
.youtube-player {
|
|
@apply grid aspect-video w-full place-items-center rounded-[1rem];
|
|
background: #111827;
|
|
color: #ef4444;
|
|
}
|
|
|
|
.youtube-player :deep(.v-icon) {
|
|
@apply text-6xl;
|
|
}
|
|
|
|
.youtube-meta {
|
|
@apply flex flex-col gap-2;
|
|
}
|
|
|
|
.youtube-meta strong {
|
|
@apply text-xl font-black;
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.youtube-meta p,
|
|
.x-preview p,
|
|
.feed-preview p {
|
|
@apply whitespace-pre-line text-sm leading-6;
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.x-preview {
|
|
@apply rounded-[1rem] border p-4;
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.x-preview p {
|
|
@apply text-lg leading-8;
|
|
}
|
|
|
|
.x-engagement {
|
|
@apply mt-auto grid grid-cols-4 gap-2 border-t pt-3 text-center;
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.feed-media {
|
|
@apply grid aspect-[4/5] w-full place-items-center rounded-[1rem];
|
|
background: linear-gradient(135deg, #0f766e, #f97316);
|
|
color: white;
|
|
}
|
|
|
|
.feed-media :deep(.v-icon) {
|
|
@apply text-5xl;
|
|
}
|
|
|
|
.preview-tags {
|
|
@apply flex flex-wrap gap-2;
|
|
}
|
|
|
|
.preview-tags span {
|
|
@apply rounded-full px-3 py-1 text-xs font-bold;
|
|
background: rgba(15, 118, 110, 0.1);
|
|
color: var(--app-color-on-tertiary);
|
|
}
|
|
|
|
.preview-empty-state {
|
|
@apply grid min-h-[24rem] place-items-center rounded-[1.25rem] border p-8 text-center;
|
|
background: var(--app-color-on-primary);
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.preview-empty-state strong {
|
|
@apply text-lg font-black;
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.preview-empty-state span {
|
|
@apply mt-2 block max-w-sm text-sm leading-6;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.network-pill {
|
|
@apply rounded-full border px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
|
|
background: var(--app-control-subtle);
|
|
border-color: var(--app-border-subtle);
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.channel-suggestions {
|
|
@apply flex flex-wrap gap-2;
|
|
}
|
|
|
|
.placement-card,
|
|
.media-card {
|
|
@apply rounded-[1.25rem] border p-4;
|
|
background: var(--app-color-on-primary);
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.identity-row {
|
|
@apply flex items-center gap-3;
|
|
}
|
|
|
|
.identity-row.align-start {
|
|
@apply items-start;
|
|
}
|
|
|
|
.timeline-row {
|
|
@apply flex flex-col gap-3 rounded-[1rem] border p-4;
|
|
background: #f8fafc;
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.timeline-row-header {
|
|
@apply flex items-start justify-between gap-3;
|
|
}
|
|
|
|
.timeline-row p,
|
|
.asset-revision-row p {
|
|
@apply text-sm leading-6;
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.timeline-actions {
|
|
@apply flex items-center justify-between gap-3;
|
|
}
|
|
|
|
.timeline-list.compact .timeline-row {
|
|
@apply p-3;
|
|
}
|
|
|
|
.tab-strip {
|
|
@apply grid grid-cols-2 gap-2;
|
|
}
|
|
|
|
.tab-button {
|
|
@apply flex items-center justify-between gap-2 rounded-[1rem] border px-3 py-2 text-sm font-bold transition;
|
|
background: var(--app-control-subtle);
|
|
border-color: var(--app-border-subtle);
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.tab-button.active {
|
|
background: var(--app-color-on-surface);
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.tab-button span {
|
|
@apply rounded-full px-2 py-0.5 text-xs;
|
|
background: rgba(255, 255, 255, 0.22);
|
|
}
|
|
|
|
.asset-form {
|
|
@apply grid gap-4 sm:grid-cols-2;
|
|
}
|
|
|
|
.asset-card {
|
|
@apply flex flex-col gap-4 rounded-[1rem] border p-4;
|
|
background: var(--app-color-on-primary);
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.asset-card .timeline-row-header span,
|
|
.asset-revision-row small {
|
|
@apply text-sm leading-6;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.asset-link {
|
|
@apply w-fit text-sm font-semibold;
|
|
color: var(--app-color-on-tertiary);
|
|
}
|
|
|
|
.revision-pill {
|
|
@apply rounded-full px-3 py-1 text-xs font-bold;
|
|
background: rgba(15, 118, 110, 0.12);
|
|
color: var(--app-color-on-tertiary);
|
|
}
|
|
|
|
.asset-revisions {
|
|
@apply flex flex-col gap-2;
|
|
}
|
|
|
|
.asset-revision-row {
|
|
@apply rounded-[0.875rem] border px-3 py-2;
|
|
background: rgba(255, 255, 255, 0.7);
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.asset-revision-row span {
|
|
@apply mr-2 text-sm font-bold;
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.compact-form {
|
|
@apply gap-3;
|
|
}
|
|
|
|
.link-button {
|
|
@apply text-sm font-semibold;
|
|
color: var(--app-color-on-tertiary);
|
|
}
|
|
|
|
.calendar-event-popover {
|
|
@apply fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4;
|
|
}
|
|
|
|
.calendar-event-card {
|
|
@apply flex w-full max-w-md flex-col gap-4 rounded-[1.25rem] border p-5 shadow-2xl;
|
|
background: var(--app-color-surface);
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.calendar-event-header {
|
|
@apply flex items-start justify-between gap-4;
|
|
}
|
|
|
|
.calendar-event-header div {
|
|
@apply flex flex-col gap-1;
|
|
}
|
|
|
|
.calendar-event-header span,
|
|
.calendar-event-card p {
|
|
@apply text-sm leading-6;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.calendar-event-header strong {
|
|
@apply text-xl font-black;
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.calendar-event-actions {
|
|
@apply flex justify-end;
|
|
}
|
|
|
|
@media (max-width: 1279px) {
|
|
.side-panel {
|
|
position: static;
|
|
max-height: none;
|
|
overflow: visible;
|
|
}
|
|
}
|
|
</style>
|