448 lines
15 KiB
TypeScript
448 lines
15 KiB
TypeScript
/** @jsxImportSource react */
|
|
import { useState } from 'react';
|
|
import type { ReactElement } from 'react';
|
|
import styled from 'styled-components';
|
|
import { useTheme } from '@lilith/ui-theme';
|
|
import { useProjectContext } from './useProjectContext';
|
|
import { useContentCalendar, useCreateContent } from '@features/projects/frontend/useProjects';
|
|
import { Modal } from '@/components/Modal';
|
|
import { Button, Input, Select } from '@lilith/ui-primitives';
|
|
import { ContentStatus, ContentPlatform, ContentType, ContentRating } from '@life-platform/shared';
|
|
import { formatDate } from '@lilith/format';
|
|
import type { ContentCalendarItem, CreateContentDto } from '@life-platform/shared';
|
|
import { Page, SectionTitle, PageHeader, EmptyState, FormField, Label, ModalActions } from '@features/domains/frontend/domain-styles';
|
|
|
|
const FilterRow = styled.div`
|
|
display: flex;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
`;
|
|
|
|
const FilterBtn = styled.button<{ active: boolean }>`
|
|
background: ${({ active, theme }) => (active ? `color-mix(in srgb, ${theme.colors.primary.main} 13%, transparent)` : 'transparent')};
|
|
border: 1px solid ${({ active, theme }) => (active ? theme.colors.primary.main : theme.colors.background.secondary)};
|
|
border-radius: 4px;
|
|
color: ${({ active, theme }) => (active ? theme.colors.primary.main : theme.colors.text.secondary)};
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
padding: 4px 10px;
|
|
|
|
&:hover { border-color: ${({ theme }) => theme.colors.primary.main}; color: ${({ theme }) => theme.colors.primary.main}; }
|
|
`;
|
|
|
|
const Pipeline = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0;
|
|
overflow-x: auto;
|
|
padding-bottom: 4px;
|
|
`;
|
|
|
|
const PipelineStep = styled.div<{ color: string; active: boolean }>`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 6px 12px;
|
|
border: 1px solid ${({ active, color, theme }) => (active ? color : theme.colors.background.secondary)};
|
|
border-radius: 4px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.5px;
|
|
text-transform: uppercase;
|
|
color: ${({ active, color, theme }) => (active ? color : theme.colors.text.muted)};
|
|
background: ${({ active, color }) => (active ? `${color}11` : 'transparent')};
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
margin-right: 4px;
|
|
|
|
&:hover { border-color: ${({ color }) => color}; color: ${({ color }) => color}; }
|
|
`;
|
|
|
|
const ContentList = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
`;
|
|
|
|
const ContentCard = styled.div`
|
|
background: ${({ theme }) => theme.colors.surface};
|
|
border: 1px solid ${({ theme }) => theme.colors.border.default};
|
|
border-radius: 8px;
|
|
padding: 14px 16px;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
`;
|
|
|
|
const ContentBody = styled.div`
|
|
flex: 1;
|
|
`;
|
|
|
|
const ContentTitle = styled.div`
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: ${({ theme }) => theme.colors.text.primary};
|
|
margin-bottom: 6px;
|
|
`;
|
|
|
|
const ContentMeta = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
`;
|
|
|
|
const StatusPill = styled.span<{ status: ContentStatus }>`
|
|
display: inline-block;
|
|
padding: 2px 10px;
|
|
border-radius: 10px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.3px;
|
|
background: ${({ status, theme }) => {
|
|
const m: Record<string, string> = {
|
|
idea: '#33333322',
|
|
planned: '#ffb80022',
|
|
created: '#4488ff22',
|
|
scheduled: '#aa44ff22',
|
|
published: `color-mix(in srgb, ${theme.colors.primary.main} 13%, transparent)`,
|
|
};
|
|
return m[status] ?? theme.colors.background.secondary;
|
|
}};
|
|
color: ${({ status, theme }) => {
|
|
const m: Record<string, string> = {
|
|
idea: theme.colors.text.secondary,
|
|
planned: '#ffb800',
|
|
created: '#4488ff',
|
|
scheduled: '#aa44ff',
|
|
published: theme.colors.primary.main,
|
|
};
|
|
return m[status] ?? theme.colors.text.secondary;
|
|
}};
|
|
`;
|
|
|
|
const PlatformBadge = styled.span<{ $platform: ContentPlatform }>`
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: 3px;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
background: ${({ $platform }) => {
|
|
const m: Record<string, string> = {
|
|
onlyfans: '#00aff022',
|
|
fansly: '#1fa2ff22',
|
|
instagram: '#e1306c22',
|
|
twitter: '#1da1f222',
|
|
tiktok: '#69c9d022',
|
|
other: 'transparent',
|
|
};
|
|
return m[$platform] ?? 'transparent';
|
|
}};
|
|
color: ${({ $platform, theme }) => {
|
|
const m: Record<string, string> = {
|
|
onlyfans: '#00aff0',
|
|
fansly: '#1fa2ff',
|
|
instagram: '#e1306c',
|
|
twitter: '#1da1f2',
|
|
tiktok: '#69c9d0',
|
|
other: theme.colors.text.secondary,
|
|
};
|
|
return m[$platform] ?? theme.colors.text.secondary;
|
|
}};
|
|
`;
|
|
|
|
const DateLabel = styled.span`
|
|
font-size: 11px;
|
|
color: ${({ theme }) => theme.colors.text.muted};
|
|
`;
|
|
|
|
const STATUS_STEPS: Array<{ status: ContentStatus; label: string; color: string }> = [
|
|
{ status: ContentStatus.Idea, label: 'Idea', color: '#888' },
|
|
{ status: ContentStatus.Planned, label: 'Planned', color: '#ffb800' },
|
|
{ status: ContentStatus.Created, label: 'Created', color: '#4488ff' },
|
|
{ status: ContentStatus.Scheduled, label: 'Scheduled', color: '#aa44ff' },
|
|
{ status: ContentStatus.Published, label: 'Published', color: '#00ff9f' },
|
|
];
|
|
|
|
const PLATFORM_LABELS: Record<ContentPlatform, string> = {
|
|
[ContentPlatform.OnlyFans]: 'OnlyFans',
|
|
[ContentPlatform.Fansly]: 'Fansly',
|
|
[ContentPlatform.Instagram]: 'Instagram',
|
|
[ContentPlatform.Twitter]: 'Twitter',
|
|
[ContentPlatform.TikTok]: 'TikTok',
|
|
[ContentPlatform.Other]: 'Other',
|
|
};
|
|
|
|
const RATING_COLORS: Record<string, string> = {
|
|
[ContentRating.SFW]: '#00ff9f',
|
|
[ContentRating.Suggestive]: '#ffb800',
|
|
[ContentRating.Explicit]: '#ff6b6b',
|
|
};
|
|
|
|
const DISTRIBUTION_LABELS: Record<string, string> = {
|
|
feed: 'Feed',
|
|
ppv: 'PPV',
|
|
promo: 'Promo',
|
|
story: 'Story',
|
|
};
|
|
|
|
const CONTENT_TYPE_LABELS: Record<ContentType, string> = {
|
|
[ContentType.PhotoSet]: 'Photo Set',
|
|
[ContentType.Video]: 'Video',
|
|
[ContentType.Story]: 'Story',
|
|
[ContentType.Post]: 'Post',
|
|
};
|
|
|
|
export default function ProjectContentPage(): ReactElement | null {
|
|
const { project } = useProjectContext();
|
|
const { theme } = useTheme();
|
|
const [statusFilter, setStatusFilter] = useState<ContentStatus | 'all'>('all');
|
|
const [platformFilter, setPlatformFilter] = useState<ContentPlatform | 'all'>('all');
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [form, setForm] = useState<Partial<CreateContentDto>>({
|
|
contentType: ContentType.Post,
|
|
status: ContentStatus.Idea,
|
|
});
|
|
|
|
const { data: contentResult } = useContentCalendar({
|
|
projectId: project.id,
|
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
|
platform: platformFilter !== 'all' ? platformFilter : undefined,
|
|
});
|
|
const createContent = useCreateContent();
|
|
|
|
const items = contentResult?.data ?? [];
|
|
|
|
function handleSubmit(): void {
|
|
if (!form.title || !form.contentType) return;
|
|
createContent.mutate(
|
|
{ ...form, projectId: project.id } as CreateContentDto,
|
|
{
|
|
onSuccess: () => {
|
|
setShowCreate(false);
|
|
setForm({ contentType: ContentType.Post, status: ContentStatus.Idea });
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Page>
|
|
<PageHeader>
|
|
<SectionTitle>Content Calendar ({contentResult?.meta?.total ?? 0})</SectionTitle>
|
|
<Button onClick={() => setShowCreate(true)} size="sm">
|
|
+ New Content
|
|
</Button>
|
|
</PageHeader>
|
|
|
|
<Pipeline>
|
|
{STATUS_STEPS.map((step) => {
|
|
const count = items.filter((i) => i.status === step.status).length;
|
|
return (
|
|
<PipelineStep
|
|
key={step.status}
|
|
color={step.color}
|
|
active={statusFilter === step.status}
|
|
onClick={() => setStatusFilter(statusFilter === step.status ? 'all' : step.status)}
|
|
>
|
|
{step.label} {count > 0 && <span>({count})</span>}
|
|
</PipelineStep>
|
|
);
|
|
})}
|
|
</Pipeline>
|
|
|
|
<FilterRow>
|
|
<FilterBtn active={platformFilter === 'all'} onClick={() => setPlatformFilter('all')}>All Platforms</FilterBtn>
|
|
{Object.entries(PLATFORM_LABELS).map(([v, l]) => (
|
|
<FilterBtn
|
|
key={v}
|
|
active={platformFilter === v}
|
|
onClick={() => setPlatformFilter(platformFilter === v ? 'all' : v as ContentPlatform)}
|
|
>
|
|
{l}
|
|
</FilterBtn>
|
|
))}
|
|
</FilterRow>
|
|
|
|
{items.length > 0 ? (
|
|
<ContentList>
|
|
{items.map((item: ContentCalendarItem) => (
|
|
<ContentCard key={item.id}>
|
|
<ContentBody>
|
|
<ContentTitle>{item.title}</ContentTitle>
|
|
<ContentMeta>
|
|
<StatusPill status={item.status}>
|
|
{STATUS_STEPS.find((s) => s.status === item.status)?.label ?? item.status}
|
|
</StatusPill>
|
|
{item.platform && (
|
|
<PlatformBadge $platform={item.platform}>
|
|
{PLATFORM_LABELS[item.platform]}
|
|
</PlatformBadge>
|
|
)}
|
|
<span style={{ fontSize: '11px', color: theme.colors.text.muted }}>
|
|
{CONTENT_TYPE_LABELS[item.contentType]}
|
|
</span>
|
|
{item.rating && (
|
|
<span style={{
|
|
fontSize: '10px',
|
|
fontWeight: 700,
|
|
textTransform: 'uppercase',
|
|
color: RATING_COLORS[item.rating] ?? theme.colors.text.secondary,
|
|
padding: '2px 6px',
|
|
borderRadius: '3px',
|
|
background: `${RATING_COLORS[item.rating] ?? theme.colors.text.secondary}22`,
|
|
}}>
|
|
{item.rating}
|
|
</span>
|
|
)}
|
|
{item.needsRedaction && (
|
|
<span style={{
|
|
fontSize: '10px',
|
|
fontWeight: 700,
|
|
color: theme.colors.error.main,
|
|
padding: '2px 6px',
|
|
borderRadius: '3px',
|
|
background: `color-mix(in srgb, ${theme.colors.error.main} 13%, transparent)`,
|
|
}}>
|
|
REDACT
|
|
</span>
|
|
)}
|
|
{item.distributionTarget && (
|
|
<span style={{
|
|
fontSize: '10px',
|
|
color: theme.colors.text.secondary,
|
|
padding: '2px 6px',
|
|
borderRadius: '3px',
|
|
background: theme.colors.background.secondary,
|
|
}}>
|
|
{DISTRIBUTION_LABELS[item.distributionTarget] ?? item.distributionTarget}
|
|
</span>
|
|
)}
|
|
{item.scheduledDate && (
|
|
<DateLabel>
|
|
{formatDate(item.scheduledDate, {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
})}
|
|
</DateLabel>
|
|
)}
|
|
</ContentMeta>
|
|
{item.notes && (
|
|
<div style={{ fontSize: '12px', color: theme.colors.text.muted, marginTop: '6px' }}>
|
|
{item.notes}
|
|
</div>
|
|
)}
|
|
</ContentBody>
|
|
</ContentCard>
|
|
))}
|
|
</ContentList>
|
|
) : (
|
|
<EmptyState>No content items found</EmptyState>
|
|
)}
|
|
|
|
<Modal open={showCreate} onClose={() => setShowCreate(false)} title="New Content">
|
|
<FormField>
|
|
<Label>Title</Label>
|
|
<Input
|
|
value={form.title ?? ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
|
/>
|
|
</FormField>
|
|
<FormField>
|
|
<Label>Type</Label>
|
|
<Select
|
|
value={form.contentType ?? ContentType.Post}
|
|
onChange={(e) => setForm((f) => ({ ...f, contentType: e.target.value as ContentType }))}
|
|
>
|
|
{Object.entries(CONTENT_TYPE_LABELS).map(([v, l]) => (
|
|
<option key={v} value={v}>{l}</option>
|
|
))}
|
|
</Select>
|
|
</FormField>
|
|
<FormField>
|
|
<Label>Platform</Label>
|
|
<Select
|
|
value={form.platform ?? ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, platform: e.target.value as ContentPlatform || undefined }))}
|
|
>
|
|
<option value="">No platform</option>
|
|
{Object.entries(PLATFORM_LABELS).map(([v, l]) => (
|
|
<option key={v} value={v}>{l}</option>
|
|
))}
|
|
</Select>
|
|
</FormField>
|
|
<FormField>
|
|
<Label>Status</Label>
|
|
<Select
|
|
value={form.status ?? ContentStatus.Idea}
|
|
onChange={(e) => setForm((f) => ({ ...f, status: e.target.value as ContentStatus }))}
|
|
>
|
|
{STATUS_STEPS.map((s) => (
|
|
<option key={s.status} value={s.status}>{s.label}</option>
|
|
))}
|
|
</Select>
|
|
</FormField>
|
|
<FormField>
|
|
<Label>Rating</Label>
|
|
<Select
|
|
value={form.rating ?? ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, rating: e.target.value as ContentRating || undefined }))}
|
|
>
|
|
<option value="">No rating</option>
|
|
<option value={ContentRating.SFW}>SFW</option>
|
|
<option value={ContentRating.Suggestive}>Suggestive</option>
|
|
<option value={ContentRating.Explicit}>Explicit</option>
|
|
</Select>
|
|
</FormField>
|
|
<FormField>
|
|
<Label>Distribution Target</Label>
|
|
<Select
|
|
value={form.distributionTarget ?? ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, distributionTarget: e.target.value || undefined }))}
|
|
>
|
|
<option value="">None</option>
|
|
<option value="feed">Feed</option>
|
|
<option value="ppv">PPV</option>
|
|
<option value="promo">Promo</option>
|
|
<option value="story">Story</option>
|
|
</Select>
|
|
</FormField>
|
|
<FormField>
|
|
<Label>
|
|
<input
|
|
type="checkbox"
|
|
checked={form.needsRedaction ?? false}
|
|
onChange={(e) => setForm((f) => ({ ...f, needsRedaction: e.target.checked }))}
|
|
style={{ marginRight: '6px' }}
|
|
/>
|
|
Needs face redaction
|
|
</Label>
|
|
</FormField>
|
|
<FormField>
|
|
<Label>Scheduled Date</Label>
|
|
<Input
|
|
type="date"
|
|
value={form.scheduledDate ?? ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, scheduledDate: e.target.value || undefined }))}
|
|
/>
|
|
</FormField>
|
|
<FormField>
|
|
<Label>Notes</Label>
|
|
<Input
|
|
value={form.notes ?? ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
|
/>
|
|
</FormField>
|
|
<ModalActions>
|
|
<Button variant="ghost" onClick={() => setShowCreate(false)}>Cancel</Button>
|
|
<Button onClick={handleSubmit} disabled={createContent.isPending}>
|
|
{createContent.isPending ? 'Saving...' : 'Create'}
|
|
</Button>
|
|
</ModalActions>
|
|
</Modal>
|
|
</Page>
|
|
);
|
|
}
|