life-manager/codebase/features/projects/frontend/ProjectContentPage.tsx
2026-03-17 17:51:06 -07:00

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>
);
}