Implement creator studio and upload updates
This commit is contained in:
303
resources/js/Pages/Studio/StudioPreferences.jsx
Normal file
303
resources/js/Pages/Studio/StudioPreferences.jsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
|
||||
const shortcutOptions = [
|
||||
{ value: '/dashboard/profile', label: 'Dashboard profile' },
|
||||
{ value: '/dashboard/notifications', label: 'Notifications' },
|
||||
{ value: '/dashboard/comments/received', label: 'Received comments' },
|
||||
{ value: '/dashboard/followers', label: 'Followers' },
|
||||
{ value: '/dashboard/following', label: 'Following' },
|
||||
{ value: '/dashboard/favorites', label: 'Favorites' },
|
||||
{ value: '/dashboard/artworks', label: 'Artwork dashboard' },
|
||||
{ value: '/dashboard/gallery', label: 'Gallery' },
|
||||
{ value: '/dashboard/awards', label: 'Awards' },
|
||||
{ value: '/creator/stories', label: 'Story dashboard' },
|
||||
{ value: '/studio', label: 'Creator Studio' },
|
||||
]
|
||||
|
||||
const widgetOptions = [
|
||||
{ value: 'quick_stats', label: 'Quick stats' },
|
||||
{ value: 'continue_working', label: 'Continue working' },
|
||||
{ value: 'scheduled_items', label: 'Scheduled items' },
|
||||
{ value: 'recent_activity', label: 'Recent activity' },
|
||||
{ value: 'top_performers', label: 'Top performers' },
|
||||
{ value: 'draft_reminders', label: 'Draft reminders' },
|
||||
{ value: 'module_summaries', label: 'Module summaries' },
|
||||
{ value: 'growth_hints', label: 'Growth hints' },
|
||||
{ value: 'active_challenges', label: 'Active challenges' },
|
||||
{ value: 'creator_health', label: 'Creator health' },
|
||||
{ value: 'featured_status', label: 'Featured status' },
|
||||
{ value: 'comments_snapshot', label: 'Comments snapshot' },
|
||||
{ value: 'stale_drafts', label: 'Stale drafts' },
|
||||
]
|
||||
|
||||
const landingOptions = [
|
||||
['overview', 'Overview'],
|
||||
['content', 'Content'],
|
||||
['drafts', 'Drafts'],
|
||||
['scheduled', 'Scheduled'],
|
||||
['calendar', 'Calendar'],
|
||||
['inbox', 'Inbox'],
|
||||
['analytics', 'Analytics'],
|
||||
['growth', 'Growth'],
|
||||
['challenges', 'Challenges'],
|
||||
['search', 'Search'],
|
||||
['preferences', 'Preferences'],
|
||||
]
|
||||
|
||||
async function requestJson(url, method, body) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || 'Request failed')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
export default function StudioPreferences() {
|
||||
const { props } = usePage()
|
||||
const preferences = props.preferences || {}
|
||||
const [form, setForm] = useState({
|
||||
default_content_view: preferences.default_content_view || 'grid',
|
||||
analytics_range_days: preferences.analytics_range_days || 30,
|
||||
dashboard_shortcuts: preferences.dashboard_shortcuts || [],
|
||||
draft_behavior: preferences.draft_behavior || 'resume-last',
|
||||
default_landing_page: preferences.default_landing_page || 'overview',
|
||||
widget_visibility: preferences.widget_visibility || {},
|
||||
widget_order: preferences.widget_order || widgetOptions.map((option) => option.value),
|
||||
card_density: preferences.card_density || 'comfortable',
|
||||
scheduling_timezone: preferences.scheduling_timezone || '',
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setForm({
|
||||
default_content_view: preferences.default_content_view || 'grid',
|
||||
analytics_range_days: preferences.analytics_range_days || 30,
|
||||
dashboard_shortcuts: preferences.dashboard_shortcuts || [],
|
||||
draft_behavior: preferences.draft_behavior || 'resume-last',
|
||||
default_landing_page: preferences.default_landing_page || 'overview',
|
||||
widget_visibility: preferences.widget_visibility || {},
|
||||
widget_order: preferences.widget_order || widgetOptions.map((option) => option.value),
|
||||
card_density: preferences.card_density || 'comfortable',
|
||||
scheduling_timezone: preferences.scheduling_timezone || '',
|
||||
})
|
||||
}, [preferences])
|
||||
|
||||
const toggleShortcut = (value) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
dashboard_shortcuts: current.dashboard_shortcuts.includes(value)
|
||||
? current.dashboard_shortcuts.filter((entry) => entry !== value)
|
||||
: [...current.dashboard_shortcuts, value].slice(0, 8),
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleWidget = (value) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
widget_visibility: {
|
||||
...current.widget_visibility,
|
||||
[value]: !(current.widget_visibility?.[value] !== false),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const moveWidget = (value, direction) => {
|
||||
setForm((current) => {
|
||||
const items = [...current.widget_order]
|
||||
const index = items.indexOf(value)
|
||||
if (index < 0) return current
|
||||
const nextIndex = direction === 'up' ? index - 1 : index + 1
|
||||
if (nextIndex < 0 || nextIndex >= items.length) return current
|
||||
const swapped = items[nextIndex]
|
||||
items[nextIndex] = value
|
||||
items[index] = swapped
|
||||
|
||||
trackStudioEvent('studio_widget_reordered', {
|
||||
surface: studioSurface(),
|
||||
module: 'preferences',
|
||||
meta: {
|
||||
widget: value,
|
||||
direction,
|
||||
from: index + 1,
|
||||
to: nextIndex + 1,
|
||||
},
|
||||
})
|
||||
|
||||
return { ...current, widget_order: items }
|
||||
})
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
await requestJson(props.endpoints.save, 'PUT', form)
|
||||
window.alert('Studio preferences saved.')
|
||||
} catch (error) {
|
||||
window.alert(error?.message || 'Unable to save Studio preferences.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Workspace preferences</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Choose where Studio opens, how dense content cards feel, and which overview modules stay visible.</p>
|
||||
</div>
|
||||
<button type="button" onClick={saveSettings} disabled={saving} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 disabled:opacity-50">
|
||||
<i className="fa-solid fa-floppy-disk" />
|
||||
{saving ? 'Saving...' : 'Save preferences'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Default content view</span>
|
||||
<select value={form.default_content_view} onChange={(event) => setForm((current) => ({ ...current, default_content_view: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
<option value="grid" className="bg-slate-900">Grid</option>
|
||||
<option value="list" className="bg-slate-900">List</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Analytics date range</span>
|
||||
<select value={form.analytics_range_days} onChange={(event) => setForm((current) => ({ ...current, analytics_range_days: Number(event.target.value) }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
{[7, 14, 30, 60, 90].map((days) => (
|
||||
<option key={days} value={days} className="bg-slate-900">Last {days} days</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300 md:col-span-2">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Draft behavior</span>
|
||||
<select value={form.draft_behavior} onChange={(event) => setForm((current) => ({ ...current, draft_behavior: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
<option value="resume-last" className="bg-slate-900">Resume the last draft I edited</option>
|
||||
<option value="open-drafts" className="bg-slate-900">Open the drafts library first</option>
|
||||
<option value="focus-published" className="bg-slate-900">Open published content first</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Default landing page</span>
|
||||
<select value={form.default_landing_page} onChange={(event) => setForm((current) => ({ ...current, default_landing_page: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
{landingOptions.map(([value, label]) => (
|
||||
<option key={value} value={value} className="bg-slate-900">{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Card density</span>
|
||||
<select value={form.card_density} onChange={(event) => setForm((current) => ({ ...current, card_density: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
<option value="comfortable" className="bg-slate-900">Comfortable</option>
|
||||
<option value="compact" className="bg-slate-900">Compact</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300 md:col-span-2">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Scheduling timezone</span>
|
||||
<input value={form.scheduling_timezone} onChange={(event) => setForm((current) => ({ ...current, scheduling_timezone: event.target.value }))} placeholder="Europe/Helsinki" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white">Dashboard shortcuts</h3>
|
||||
<p className="mt-1 text-sm text-slate-400">Pin up to 8 destinations that should stay easy to reach from the wider workspace.</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 px-3 py-1 text-xs uppercase tracking-[0.16em] text-slate-400">{form.dashboard_shortcuts.length}/8 selected</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{shortcutOptions.map((option) => {
|
||||
const active = form.dashboard_shortcuts.includes(option.value)
|
||||
|
||||
return (
|
||||
<button key={option.value} type="button" onClick={() => toggleShortcut(option.value)} className={`flex items-center justify-between rounded-[22px] border px-4 py-3 text-left transition ${active ? 'border-sky-300/25 bg-sky-300/10 text-sky-100' : 'border-white/10 bg-black/20 text-slate-300'}`}>
|
||||
<span>{option.label}</span>
|
||||
<i className={`fa-solid ${active ? 'fa-circle-check' : 'fa-circle'} text-sm`} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h3 className="text-base font-semibold text-white">Overview widgets</h3>
|
||||
<p className="mt-1 text-sm text-slate-400">Show, hide, and prioritize dashboard sections for your daily workflow.</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{form.widget_order.map((widgetKey, index) => {
|
||||
const option = widgetOptions.find((entry) => entry.value === widgetKey)
|
||||
if (!option) return null
|
||||
const enabled = form.widget_visibility?.[widgetKey] !== false
|
||||
|
||||
return (
|
||||
<div key={widgetKey} className="flex flex-col gap-3 rounded-[22px] border border-white/10 bg-black/20 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{option.label}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">Position {index + 1}</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" onClick={() => toggleWidget(widgetKey)} className={`rounded-full border px-3 py-1.5 text-xs ${enabled ? 'border-sky-300/25 bg-sky-300/10 text-sky-100' : 'border-white/10 text-slate-300'}`}>
|
||||
{enabled ? 'Visible' : 'Hidden'}
|
||||
</button>
|
||||
<button type="button" onClick={() => moveWidget(widgetKey, 'up')} className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-300">Up</button>
|
||||
<button type="button" onClick={() => moveWidget(widgetKey, 'down')} className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-300">Down</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Related surfaces</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{(props.links || []).map((link) => (
|
||||
<a key={link.url} href={link.url} className="block rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
|
||||
<div className="flex items-center gap-3 text-sky-100">
|
||||
<i className={link.icon} />
|
||||
<span className="text-base font-semibold text-white">{link.label}</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Preference notes</h2>
|
||||
<div className="mt-4 space-y-3 text-sm text-slate-400">
|
||||
<div className="rounded-[22px] border border-white/10 bg-black/20 p-4">Landing page and widget order are stored in the shared Studio preference record, so new Creator Studio surfaces can plug into the same contract without another migration.</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-black/20 p-4">Analytics range and card density stay here so Analytics, Growth, and the main dashboard can stay visually consistent.</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user