feat: ship creator journey v2 and profile updates

This commit is contained in:
2026-04-12 21:42:07 +02:00
parent a2457f4e49
commit d5cff21ea2
335 changed files with 20147 additions and 1545 deletions

View File

@@ -104,7 +104,7 @@ export default function StudioArtworkAnalytics() {
<div className="text-center">
<i className="fa-solid fa-share-nodes text-3xl text-slate-700 mb-3" />
<p className="text-xs text-slate-500">Coming soon</p>
<p className="text-[10px] text-slate-600 mt-1">Platform-level share tracking coming in v2</p>
<p className="text-[10px] text-slate-600 mt-1">Per-platform breakdown coming in a future update</p>
</div>
</div>
</div>

View File

@@ -10,10 +10,12 @@ import Toggle from '../../components/ui/Toggle'
import NovaSelect from '../../components/ui/NovaSelect'
import TagPicker from '../../components/tags/TagPicker'
import SchedulePublishPicker from '../../components/upload/SchedulePublishPicker'
import ArtworkEvolutionSearchPicker from '../../components/artwork/ArtworkEvolutionSearchPicker'
const EDIT_SECTIONS = [
{ id: 'taxonomy', label: 'Category', hint: 'Content type and category path' },
{ id: 'details', label: 'Details', hint: 'Title and description' },
{ id: 'evolution', label: 'Evolution', hint: 'Link an older original artwork' },
{ id: 'ai-assist', label: 'AI Assist', hint: 'Suggestions and similar matches' },
{ id: 'tags', label: 'Tags', hint: 'Search, add, and refine keywords' },
{ id: 'visibility', label: 'Visibility', hint: 'Publishing state' },
@@ -21,6 +23,7 @@ const EDIT_SECTIONS = [
const TABS = [
{ id: 'details', label: 'Details', icon: 'fa-solid fa-pen-fancy' },
{ id: 'evolution', label: 'Evolution', icon: 'fa-solid fa-code-branch' },
{ id: 'tags', label: 'Tags', icon: 'fa-solid fa-tags' },
{ id: 'taxonomy', label: 'Category', icon: 'fa-solid fa-palette' },
{ id: 'visibility', label: 'Visibility', icon: 'fa-solid fa-eye' },
@@ -40,6 +43,51 @@ function formatBytes(bytes) {
return (bytes / 1048576).toFixed(1) + ' MB'
}
function formatSchedulePreview(value, timezone) {
if (!value) return 'Pick a date and time'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Pick a date and time'
try {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: timezone || undefined,
}).format(date)
} catch {
return date.toLocaleString()
}
}
function formatReleaseCountdown(value, nowMs = Date.now()) {
if (!value) return ''
const releaseDate = new Date(value)
if (Number.isNaN(releaseDate.getTime())) return ''
const remainingMs = releaseDate.getTime() - nowMs
if (remainingMs <= 0) {
return 'Releasing now'
}
const totalSeconds = Math.floor(remainingMs / 1000)
const days = Math.floor(totalSeconds / 86400)
const hours = Math.floor((totalSeconds % 86400) / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const parts = []
if (days > 0) parts.push(`${days}d`)
if (days > 0 || hours > 0) parts.push(`${hours}h`)
if (days > 0 || hours > 0 || minutes > 0) parts.push(`${minutes}m`)
if (days === 0) parts.push(`${seconds}s`)
return `In ${parts.join(' ')}`
}
function getContentTypeVisualKey(slug) {
const map = { skins: 'skins', wallpapers: 'wallpapers', photography: 'photography', other: 'other', members: 'members' }
return map[slug] || 'other'
@@ -206,6 +254,8 @@ export default function StudioArtworkEdit() {
const { props } = usePage()
const { artwork, contentTypes: rawContentTypes } = props
const groupOptions = Array.isArray(props.groupOptions) ? props.groupOptions : []
const evolutionRelationTypes = Array.isArray(props.evolutionRelationTypes) ? props.evolutionRelationTypes : []
const initialEvolutionRelation = artwork?.evolution_relation || null
const contributorOptionsByGroup = props.contributorOptionsByGroup && typeof props.contributorOptionsByGroup === 'object'
? props.contributorOptionsByGroup
: {}
@@ -230,6 +280,9 @@ export default function StudioArtworkEdit() {
const [descriptionSource, setDescriptionSource] = useState(artwork?.description_source || 'manual')
const [tagsSource, setTagsSource] = useState(artwork?.tags_source || 'manual')
const [categorySource, setCategorySource] = useState(artwork?.category_source || 'manual')
const [evolutionTarget, setEvolutionTarget] = useState(initialEvolutionRelation?.target_artwork || null)
const [evolutionRelationType, setEvolutionRelationType] = useState(initialEvolutionRelation?.relation_type || evolutionRelationTypes[0]?.value || 'remake_of')
const [evolutionNote, setEvolutionNote] = useState(initialEvolutionRelation?.note || '')
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [errors, setErrors] = useState({})
@@ -243,6 +296,7 @@ export default function StudioArtworkEdit() {
const [selectedAiTags, setSelectedAiTags] = useState([])
const [activeTab, setActiveTab] = useState('details')
const [isCategoryChooserOpen, setIsCategoryChooserOpen] = useState(() => !artwork?.parent_category_id)
const [nowMs, setNowMs] = useState(() => Date.now())
const userTimezone = useMemo(() => artwork?.artwork_timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [artwork?.artwork_timezone])
// File replace
@@ -282,11 +336,27 @@ export default function StudioArtworkEdit() {
const visibilitySummary = publishMode === 'schedule'
? `Scheduled as ${visibilityLabel(visibility)}`
: visibilityLabel(visibility)
const selectedSubCategory = subCategoryId ? subCategories.find((item) => item.id === subCategoryId) || null : null
const heroMeta = [
selectedCT?.name || 'No content type',
selectedRoot?.name || 'No root category',
subCategoryId ? subCategories.find((item) => item.id === subCategoryId)?.name : null,
selectedSubCategory?.name || null,
].filter(Boolean)
const categoryPreviewSummary = [selectedCT?.name, selectedRoot?.name, selectedSubCategory?.name].filter(Boolean).join(' / ') || 'Choose a category path'
const visibilityPreviewHint = publishMode === 'schedule'
? 'Hidden until the scheduled publish time.'
: visibility === 'private'
? 'Draft-only visibility.'
: visibility === 'unlisted'
? 'Accessible by direct link.'
: 'Visible to everyone immediately.'
const hasScheduledRelease = publishMode === 'schedule' && Boolean(scheduledAt)
const schedulePreviewSummary = hasScheduledRelease
? formatReleaseCountdown(scheduledAt, nowMs)
: ''
const schedulePreviewHint = hasScheduledRelease
? formatSchedulePreview(scheduledAt, userTimezone)
: ''
const publishingIdentityOptions = useMemo(() => {
const personalOption = {
value: '',
@@ -310,6 +380,7 @@ export default function StudioArtworkEdit() {
username: user.username,
avatarUrl: user.avatar_url || null,
})), [currentContributorOptions])
const selectedEvolutionType = evolutionRelationTypes.find((option) => String(option.value) === String(evolutionRelationType)) || evolutionRelationTypes[0] || null
// ── Handlers ───────────────────────────────────────────────────────────────
const handleContentTypeChange = (id) => {
@@ -572,6 +643,16 @@ export default function StudioArtworkEdit() {
return () => window.clearInterval(timer)
}, [aiStatus, loadAiData])
useEffect(() => {
if (!hasScheduledRelease) return undefined
const timer = window.setInterval(() => {
setNowMs(Date.now())
}, 1000)
return () => window.clearInterval(timer)
}, [hasScheduledRelease])
useEffect(() => {
const selectedSlug = String(groupSlug || '')
if (!selectedSlug) {
@@ -640,6 +721,9 @@ export default function StudioArtworkEdit() {
description_source: descriptionSource,
tags_source: tagsSource,
category_source: categorySource,
evolution_target_artwork_id: evolutionTarget?.id || null,
evolution_relation_type: evolutionTarget ? evolutionRelationType : null,
evolution_note: evolutionTarget ? evolutionNote : null,
}
const res = await fetch(`/api/studio/artworks/${artwork.id}`, {
method: 'PUT',
@@ -650,6 +734,7 @@ export default function StudioArtworkEdit() {
if (res.ok) {
const data = await res.json()
const updatedArtwork = data?.artwork || null
const updatedEvolutionRelation = data?.evolution_relation || updatedArtwork?.evolution_relation || null
if (updatedArtwork) {
setVisibility(updatedArtwork.visibility || visibility)
setPublishMode(updatedArtwork.publish_mode || 'now')
@@ -659,6 +744,9 @@ export default function StudioArtworkEdit() {
setContributorUserIds(Array.isArray(updatedArtwork.contributor_user_ids) ? updatedArtwork.contributor_user_ids.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0) : [])
setContributorCredits(normalizeContributorCredits(updatedArtwork.contributor_user_ids || [], mapContributorCredits(updatedArtwork.contributor_credits || [])))
}
setEvolutionTarget(updatedEvolutionRelation?.target_artwork || null)
setEvolutionRelationType(updatedEvolutionRelation?.relation_type || evolutionRelationTypes[0]?.value || 'remake_of')
setEvolutionNote(updatedEvolutionRelation?.note || '')
setSaved(true)
setTimeout(() => setSaved(false), 3000)
} else {
@@ -670,7 +758,7 @@ export default function StudioArtworkEdit() {
} finally {
setSaving(false)
}
}, [title, description, visibility, publishMode, scheduledAt, userTimezone, groupSlug, primaryAuthorUserId, contributorUserIds, contributorCredits, contentTypeId, selectedLeafCategoryId, tagSlugs, titleSource, descriptionSource, tagsSource, categorySource, artwork?.id])
}, [title, description, visibility, publishMode, scheduledAt, userTimezone, groupSlug, primaryAuthorUserId, contributorUserIds, contributorCredits, contentTypeId, selectedLeafCategoryId, tagSlugs, titleSource, descriptionSource, tagsSource, categorySource, evolutionTarget, evolutionRelationType, evolutionNote, artwork?.id, evolutionRelationTypes])
const handleFileReplace = async (e) => {
const file = e.target.files?.[0]
@@ -785,7 +873,7 @@ export default function StudioArtworkEdit() {
<div className="grid grid-cols-1 gap-6 items-start xl:grid-cols-[300px_minmax(0,1fr)]">
{/* ─────────── LEFT SIDEBAR ─────────── */}
<div className="space-y-4 xl:sticky xl:top-6 xl:max-h-[calc(100vh-48px)] xl:overflow-y-auto">
<div className="space-y-4 xl:sticky xl:top-6 xl:max-h-[calc(100vh-48px)] xl:overflow-y-auto xl:overscroll-contain xl:pr-1 nova-scrollbar">
{/* Preview Card */}
<Section>
@@ -897,6 +985,55 @@ export default function StudioArtworkEdit() {
</div>
</Section>
<Section className="space-y-3">
<SectionTitle icon="fa-solid fa-layer-group">Publishing Snapshot</SectionTitle>
{[
{
id: 'taxonomy',
label: 'Category',
value: categoryPreviewSummary,
hint: categorySource === 'manual' ? 'Manual category path' : `Source: ${String(categorySource).replace(/_/g, ' ')}`,
icon: 'fa-solid fa-palette',
},
{
id: 'visibility',
label: 'Visibility',
value: visibilitySummary,
hint: visibilityPreviewHint,
icon: 'fa-solid fa-eye',
},
hasScheduledRelease
? {
id: 'visibility',
label: 'Scheduler',
value: schedulePreviewSummary,
hint: schedulePreviewHint,
icon: 'fa-regular fa-clock',
}
: null,
].filter(Boolean).map((item) => (
<button
key={`${item.label}-${item.id}`}
type="button"
onClick={() => setActiveTab(item.id)}
className="group flex w-full items-start gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-left transition hover:border-white/20 hover:bg-white/[0.05]"
>
<span className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-white/[0.04] text-slate-300 transition group-hover:border-accent/30 group-hover:bg-accent/10 group-hover:text-accent">
<i className={`${item.icon} text-[13px]`} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{item.label}</span>
<span className="mt-1 block truncate text-sm font-medium text-white" title={item.value}>{item.value}</span>
<span className="mt-1 block text-xs text-slate-500">{item.hint}</span>
</span>
<span className="pt-1 text-slate-600 transition group-hover:text-slate-300" aria-hidden="true">
<i className="fa-solid fa-chevron-right text-[11px]" />
</span>
</button>
))}
</Section>
{/* Quick Links */}
<Section className="py-3 px-4">
<Link
@@ -933,6 +1070,9 @@ export default function StudioArtworkEdit() {
>
<i className={`${tab.icon} text-[11px]`} aria-hidden="true" />
{tab.label}
{tab.id === 'evolution' && evolutionTarget && (
<span className="h-1.5 w-1.5 rounded-full bg-sky-400" />
)}
{tab.id === 'ai' && aiStatus !== 'not_analyzed' && (
<span className={`h-1.5 w-1.5 rounded-full ${aiStatus === 'ready' ? 'bg-emerald-400' : aiStatus === 'failed' ? 'bg-red-400' : 'bg-sky-400'}`} />
)}
@@ -1166,12 +1306,11 @@ export default function StudioArtworkEdit() {
<SectionTitle icon="fa-solid fa-pen-fancy">Details</SectionTitle>
<TextInput
label={<FieldLabel label="Title" actionLabel="Title" onAction={() => requestAiIntent('title')} disabled={aiAction !== ''} loading={aiAction === 'analyze' || aiAction === 'regenerate'} />}
label={<FieldLabel label={<span className="inline-flex items-center gap-1">Title <span className="text-red-400">*</span></span>} actionLabel="Title" onAction={() => requestAiIntent('title')} disabled={aiAction !== ''} loading={aiAction === 'analyze' || aiAction === 'regenerate'} />}
value={title}
onChange={handleTitleChange}
placeholder="Give your artwork a title"
error={errors.title?.[0]}
required
/>
<FormField label={<FieldLabel label="Description" actionLabel="Description" onAction={() => requestAiIntent('description')} disabled={aiAction !== ''} loading={aiAction === 'analyze' || aiAction === 'regenerate'} />} htmlFor="artwork-description">
@@ -1363,6 +1502,78 @@ export default function StudioArtworkEdit() {
</Section>
)}
{/* ── Evolution tab ── */}
{activeTab === 'evolution' && (
<Section id="evolution" className="space-y-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<SectionTitle icon="fa-solid fa-code-branch">Artwork Evolution</SectionTitle>
<p className="-mt-2 text-sm text-slate-400">Connect this piece to an older public original so the artwork page can tell a clear Then & Now story in both directions.</p>
</div>
<div className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] ${evolutionTarget ? 'border-sky-300/25 bg-sky-300/10 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-300'}`}>
<i className="fa-solid fa-sparkles text-[10px]" aria-hidden="true" />
{evolutionTarget ? (selectedEvolutionType?.short_label || 'Linked') : 'No original linked'}
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
<div className="space-y-4">
<ArtworkEvolutionSearchPicker
artworkId={artwork?.id}
selected={evolutionTarget}
onSelect={(option) => setEvolutionTarget(option)}
disabled={saving}
/>
{errors.evolution_target_artwork_id?.[0] ? <p className="text-sm text-red-400">{errors.evolution_target_artwork_id[0]}</p> : null}
</div>
<div className="space-y-4">
<RightRailCard title="Relation settings">
<div className="space-y-4">
<label className="block">
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Story type</span>
<select
value={evolutionRelationType}
onChange={(event) => setEvolutionRelationType(event.target.value)}
disabled={saving || !evolutionTarget}
className="mt-2 w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35 disabled:cursor-not-allowed disabled:opacity-60"
>
{evolutionRelationTypes.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
{errors.evolution_relation_type?.[0] ? <p className="text-sm text-red-400">{errors.evolution_relation_type[0]}</p> : null}
<label className="block">
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Creator note</span>
<textarea
value={evolutionNote}
onChange={(event) => setEvolutionNote(event.target.value)}
placeholder="Optional context for viewers: what changed, what you learned, why this version matters..."
disabled={saving || !evolutionTarget}
rows={6}
className="mt-2 w-full rounded-[24px] border border-white/10 bg-[#0d1726] px-4 py-3 text-sm leading-6 text-white outline-none transition focus:border-sky-300/35 disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
{errors.evolution_note?.[0] ? <p className="text-sm text-red-400">{errors.evolution_note[0]}</p> : null}
</div>
</RightRailCard>
<RightRailCard title="Public behavior">
<div className="space-y-3 text-sm leading-relaxed text-slate-300">
<p>The newer artwork gets the main comparison panel. The older artwork gets an updated-version card pointing back here.</p>
<p>Only published public artworks can be linked, and the original has to be older than the piece you are editing.</p>
<p>{evolutionTarget ? `Current target: ${evolutionTarget.title}` : 'Pick an older artwork first to unlock relation type and note settings.'}</p>
</div>
</RightRailCard>
</div>
</div>
</Section>
)}
{/* ── AI Assist tab ── */}
{activeTab === 'ai' && (
<Section id="ai-assist" className="space-y-5">

View File

@@ -1,7 +1,8 @@
import React, { useState } from 'react'
import React, { useEffect, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import { formatReleaseCountdown, formatScheduledDate } from '../../utils/scheduleCountdown'
async function requestJson(url, method = 'POST') {
const response = await fetch(url, {
@@ -20,19 +21,13 @@ async function requestJson(url, method = 'POST') {
return payload
}
function formatDate(value) {
if (!value) return 'Not scheduled'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Not scheduled'
return date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
export default function StudioCalendar() {
const { props } = usePage()
const calendar = props.calendar || {}
const filters = calendar.filters || {}
const summary = calendar.summary || {}
const [busyKey, setBusyKey] = useState(null)
const [nowMs, setNowMs] = useState(() => Date.now())
const updateFilters = (patch) => {
const next = { ...filters, ...patch }
@@ -61,6 +56,17 @@ export default function StudioCalendar() {
}
}
useEffect(() => {
const hasTimedEntries = Boolean(summary.next_publish_at) || (calendar.scheduled_items || []).some((item) => Boolean(item.scheduled_at))
if (!hasTimedEntries) return undefined
const timer = window.setInterval(() => {
setNowMs(Date.now())
}, 1000)
return () => window.clearInterval(timer)
}, [calendar.scheduled_items, summary.next_publish_at])
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="space-y-6">
@@ -68,7 +74,7 @@ export default function StudioCalendar() {
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5"><div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Scheduled</div><div className="mt-2 text-3xl font-semibold text-white">{Number(summary.scheduled_total || 0).toLocaleString()}</div></div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5"><div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Unscheduled</div><div className="mt-2 text-3xl font-semibold text-white">{Number(summary.unscheduled_total || 0).toLocaleString()}</div></div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5"><div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Overloaded days</div><div className="mt-2 text-3xl font-semibold text-white">{Number(summary.overloaded_days || 0).toLocaleString()}</div></div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5"><div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Next publish</div><div className="mt-2 text-base font-semibold text-white">{formatDate(summary.next_publish_at)}</div></div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5"><div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Next publish</div><div className="mt-2 text-base font-semibold text-white">{formatReleaseCountdown(summary.next_publish_at, nowMs)}</div>{summary.next_publish_at && <div className="mt-1 text-sm text-slate-400">{formatScheduledDate(summary.next_publish_at)}</div>}</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 lg:p-6">
@@ -100,7 +106,7 @@ export default function StudioCalendar() {
) : filters.view === 'agenda' ? (
<>
<h2 className="text-lg font-semibold text-white">Agenda</h2>
<div className="mt-4 space-y-4">{(calendar.agenda || []).map((group) => <div key={group.date} className="rounded-[22px] border border-white/10 bg-black/20 p-4"><div className="flex items-center justify-between gap-3"><div className="text-base font-semibold text-white">{group.label}</div><div className="text-xs uppercase tracking-[0.18em] text-slate-500">{group.count} items</div></div><div className="mt-3 space-y-2">{group.items.map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 px-3 py-2 text-sm text-slate-200"><span>{item.title}</span><span className="text-xs text-slate-500">{formatDate(item.scheduled_at)}</span></a>)}</div></div>)}</div>
<div className="mt-4 space-y-4">{(calendar.agenda || []).map((group) => <div key={group.date} className="rounded-[22px] border border-white/10 bg-black/20 p-4"><div className="flex items-center justify-between gap-3"><div className="text-base font-semibold text-white">{group.label}</div><div className="text-xs uppercase tracking-[0.18em] text-slate-500">{group.count} items</div></div><div className="mt-3 space-y-2">{group.items.map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 px-3 py-2 text-sm text-slate-200"><span>{item.title}</span><span className="text-xs text-slate-500">{formatScheduledDate(item.scheduled_at)}</span></a>)}</div></div>)}</div>
</>
) : (
<>
@@ -124,7 +130,7 @@ export default function StudioCalendar() {
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between"><h2 className="text-lg font-semibold text-white">Upcoming actions</h2><a href="/studio/scheduled" className="text-sm font-medium text-sky-100">Open list</a></div>
<div className="mt-4 space-y-3">{(calendar.scheduled_items || []).slice(0, 5).map((item) => <div key={item.id} className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-sm font-semibold text-white">{item.title}</div><div className="mt-1 text-xs text-slate-500">{formatDate(item.scheduled_at)}</div><div className="mt-3 flex flex-wrap gap-2"><button type="button" disabled={busyKey === `publish:${item.id}`} onClick={() => runAction(props.endpoints.publishNowPattern, item, 'publish')} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs text-sky-100 disabled:opacity-50">Publish now</button><button type="button" disabled={busyKey === `unschedule:${item.id}`} onClick={() => runAction(props.endpoints.unschedulePattern, item, 'unschedule')} className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-200 disabled:opacity-50">Unschedule</button></div></div>)}</div>
<div className="mt-4 space-y-3">{(calendar.scheduled_items || []).slice(0, 5).map((item) => <div key={item.id} className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-sm font-semibold text-white">{item.title}</div><div className="mt-1 text-xs font-medium text-sky-200">{formatReleaseCountdown(item.scheduled_at, nowMs)}</div><div className="mt-1 text-xs text-slate-500">{formatScheduledDate(item.scheduled_at)}</div><div className="mt-3 flex flex-wrap gap-2"><button type="button" disabled={busyKey === `publish:${item.id}`} onClick={() => runAction(props.endpoints.publishNowPattern, item, 'publish')} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs text-sky-100 disabled:opacity-50">Publish now</button><button type="button" disabled={busyKey === `unschedule:${item.id}`} onClick={() => runAction(props.endpoints.unschedulePattern, item, 'unschedule')} className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-200 disabled:opacity-50">Unschedule</button></div></div>)}</div>
</section>
</aside>
</div>

View File

@@ -1,7 +1,8 @@
import React, { useState } from 'react'
import React, { useEffect, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import { formatReleaseCountdown, formatScheduledDate } from '../../utils/scheduleCountdown'
async function requestJson(url, method = 'POST') {
const response = await fetch(url, {
@@ -24,13 +25,6 @@ async function requestJson(url, method = 'POST') {
return payload
}
function formatDate(value) {
if (!value) return 'Not scheduled'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Not scheduled'
return date.toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
}
export default function StudioScheduled() {
const { props } = usePage()
const listing = props.listing || {}
@@ -42,6 +36,7 @@ export default function StudioScheduled() {
const rangeOptions = listing.range_options || []
const endpoints = props.endpoints || {}
const [busyId, setBusyId] = useState(null)
const [nowMs, setNowMs] = useState(() => Date.now())
const updateFilters = (patch) => {
const next = { ...filters, ...patch }
@@ -84,6 +79,17 @@ export default function StudioScheduled() {
}
}
useEffect(() => {
const hasTimedEntries = Boolean(summary.next_publish_at) || items.some((item) => Boolean(item.scheduled_at || item.published_at))
if (!hasTimedEntries) return undefined
const timer = window.setInterval(() => {
setNowMs(Date.now())
}, 1000)
return () => window.clearInterval(timer)
}, [items, summary.next_publish_at])
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="space-y-6">
@@ -96,7 +102,8 @@ export default function StudioScheduled() {
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 md:col-span-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Next publish slot</div>
<div className="mt-2 text-xl font-semibold text-white">{formatDate(summary.next_publish_at)}</div>
<div className="mt-2 text-xl font-semibold text-white">{formatReleaseCountdown(summary.next_publish_at, nowMs)}</div>
{summary.next_publish_at && <div className="mt-1 text-sm text-slate-400">{formatScheduledDate(summary.next_publish_at)}</div>}
</div>
</div>
@@ -172,9 +179,10 @@ export default function StudioScheduled() {
</div>
<h2 className="mt-2 text-xl font-semibold text-white">{item.title}</h2>
<div className="mt-2 flex flex-wrap items-center gap-4 text-sm text-slate-400">
<span>Scheduled for {formatDate(item.scheduled_at || item.published_at)}</span>
<span>{formatReleaseCountdown(item.scheduled_at || item.published_at, nowMs)}</span>
<span>{formatScheduledDate(item.scheduled_at || item.published_at)}</span>
{item.visibility && <span>Visibility: {item.visibility}</span>}
{item.updated_at && <span>Last edited {formatDate(item.updated_at)}</span>}
{item.updated_at && <span>Last edited {formatScheduledDate(item.updated_at)}</span>}
{item.schedule_timezone && <span>{item.schedule_timezone}</span>}
</div>
</div>