fix more stuff

This commit is contained in:
2026-08-24 22:51:07 +02:00
parent 456962fe18
commit 581f5444fb
10 changed files with 226 additions and 206 deletions
+5 -1
View File
@@ -38,6 +38,10 @@ type credentialsRequest struct {
type profileRequest struct { type profileRequest struct {
Email string `json:"email"` Email string `json:"email"`
Name string `json:"name"` Name string `json:"name"`
StudioName string `json:"studioName"`
Tagline string `json:"tagline"`
WebsiteURL string `json:"websiteUrl"`
InstagramURL string `json:"instagramUrl"`
} }
type passwordRequest struct { type passwordRequest struct {
@@ -151,7 +155,7 @@ func (h *Handler) UpdateMe(c *gin.Context) {
if !decodeJSON(c, &request) { if !decodeJSON(c, &request) {
return return
} }
updated, err := h.service.UpdateProfile(c.Request.Context(), user.ID, request.Email, request.Name) updated, err := h.service.UpdateProfile(c.Request.Context(), user.ID, request.Email, request.Name, request.StudioName, request.Tagline, request.WebsiteURL, request.InstagramURL)
if err != nil { if err != nil {
if errors.Is(err, ErrEmailTaken) { if errors.Is(err, ErrEmailTaken) {
writeJSON(c, http.StatusConflict, map[string]string{"error": "email is already registered"}) writeJSON(c, http.StatusConflict, map[string]string{"error": "email is already registered"})
+4
View File
@@ -6,6 +6,10 @@ type User struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Name string `json:"name"` Name string `json:"name"`
StudioName string `json:"studioName"`
Tagline string `json:"tagline"`
WebsiteURL string `json:"websiteUrl"`
InstagramURL string `json:"instagramUrl"`
} }
type storedUser struct { type storedUser struct {
+10 -10
View File
@@ -38,10 +38,10 @@ func (r *Repository) CreateUser(ctx context.Context, email, passwordHash, name s
func (r *Repository) FindByEmail(ctx context.Context, email string) (storedUser, error) { func (r *Repository) FindByEmail(ctx context.Context, email string) (storedUser, error) {
var user storedUser var user storedUser
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT id, email, name, password_hash SELECT id, email, name, studio_name, tagline, website_url, instagram_url, password_hash
FROM users FROM users
WHERE lower(email) = lower($1) WHERE lower(email) = lower($1)
`, strings.TrimSpace(email)).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash) `, strings.TrimSpace(email)).Scan(&user.ID, &user.Email, &user.Name, &user.StudioName, &user.Tagline, &user.WebsiteURL, &user.InstagramURL, &user.PasswordHash)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return storedUser{}, sql.ErrNoRows return storedUser{}, sql.ErrNoRows
} }
@@ -54,10 +54,10 @@ func (r *Repository) FindByEmail(ctx context.Context, email string) (storedUser,
func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (User, error) { func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (User, error) {
var user User var user User
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT id, email, name SELECT id, email, name, studio_name, tagline, website_url, instagram_url
FROM users FROM users
WHERE id = $1 WHERE id = $1
`, id).Scan(&user.ID, &user.Email, &user.Name) `, id).Scan(&user.ID, &user.Email, &user.Name, &user.StudioName, &user.Tagline, &user.WebsiteURL, &user.InstagramURL)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return User{}, sql.ErrNoRows return User{}, sql.ErrNoRows
} }
@@ -70,10 +70,10 @@ func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (User, error) {
func (r *Repository) FindByIDWithPassword(ctx context.Context, id uuid.UUID) (storedUser, error) { func (r *Repository) FindByIDWithPassword(ctx context.Context, id uuid.UUID) (storedUser, error) {
var user storedUser var user storedUser
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT id, email, name, password_hash SELECT id, email, name, studio_name, tagline, website_url, instagram_url, password_hash
FROM users FROM users
WHERE id = $1 WHERE id = $1
`, id).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash) `, id).Scan(&user.ID, &user.Email, &user.Name, &user.StudioName, &user.Tagline, &user.WebsiteURL, &user.InstagramURL, &user.PasswordHash)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return storedUser{}, sql.ErrNoRows return storedUser{}, sql.ErrNoRows
} }
@@ -83,12 +83,12 @@ func (r *Repository) FindByIDWithPassword(ctx context.Context, id uuid.UUID) (st
return user, nil return user, nil
} }
func (r *Repository) UpdateUser(ctx context.Context, id uuid.UUID, email, name string) (User, error) { func (r *Repository) UpdateUser(ctx context.Context, id uuid.UUID, email, name, studioName, tagline, websiteURL, instagramURL string) (User, error) {
_, err := r.db.ExecContext(ctx, ` _, err := r.db.ExecContext(ctx, `
UPDATE users UPDATE users
SET email = $1, name = $2, updated_at = CURRENT_TIMESTAMP SET email = $1, name = $2, studio_name = $3, tagline = $4, website_url = $5, instagram_url = $6, updated_at = CURRENT_TIMESTAMP
WHERE id = $3 WHERE id = $7
`, strings.ToLower(strings.TrimSpace(email)), strings.TrimSpace(name), id) `, strings.ToLower(strings.TrimSpace(email)), strings.TrimSpace(name), studioName, tagline, websiteURL, instagramURL, id)
if err != nil { if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") { if strings.Contains(strings.ToLower(err.Error()), "unique") {
return User{}, ErrEmailTaken return User{}, ErrEmailTaken
+2 -2
View File
@@ -80,7 +80,7 @@ func (s *Service) Login(ctx context.Context, email, password string) (User, erro
return user.User, nil return user.User, nil
} }
func (s *Service) UpdateProfile(ctx context.Context, userID uuid.UUID, email, name string) (User, error) { func (s *Service) UpdateProfile(ctx context.Context, userID uuid.UUID, email, name, studioName, tagline, websiteURL, instagramURL string) (User, error) {
email = strings.ToLower(strings.TrimSpace(email)) email = strings.ToLower(strings.TrimSpace(email))
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
if !strings.Contains(email, "@") || len(email) > 254 { if !strings.Contains(email, "@") || len(email) > 254 {
@@ -89,7 +89,7 @@ func (s *Service) UpdateProfile(ctx context.Context, userID uuid.UUID, email, na
if name == "" || len(name) > 120 { if name == "" || len(name) > 120 {
return User{}, fmt.Errorf("name is required") return User{}, fmt.Errorf("name is required")
} }
return s.repository.UpdateUser(ctx, userID, email, name) return s.repository.UpdateUser(ctx, userID, email, name, studioName, tagline, websiteURL, instagramURL)
} }
func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error { func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error {
+6 -2
View File
@@ -54,6 +54,10 @@ export interface User {
id: string; id: string;
email: string; email: string;
name: string; name: string;
studioName?: string;
tagline?: string;
websiteUrl?: string;
instagramUrl?: string;
} }
export async function getMe(): Promise<User> { export async function getMe(): Promise<User> {
@@ -81,10 +85,10 @@ export async function logout(): Promise<void> {
await request('/api/auth/logout', { method: 'POST' }); await request('/api/auth/logout', { method: 'POST' });
} }
export async function updateProfile(name: string, email: string): Promise<User> { export async function updateProfile(name: string, email: string, branding?: { studioName?: string; tagline?: string; websiteUrl?: string; instagramUrl?: string }): Promise<User> {
const response = await request<{ user: User }>('/api/auth/me', { const response = await request<{ user: User }>('/api/auth/me', {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ name, email }), body: JSON.stringify({ name, email, ...branding }),
}); });
return response.user; return response.user;
} }
@@ -13,7 +13,7 @@ import {
updateMediaOrder, updateMediaOrder,
} from '../../lib/api'; } from '../../lib/api';
import { formatBytes, formatDuration } from '../../lib/format'; import { formatBytes, formatDuration } from '../../lib/format';
import type { GalleryDetail, GalleryLayout, GalleryMode, MediaItem } from '../../types/gallery'; import type { GalleryDetail, MediaItem } from '../../types/gallery';
interface EditorDraft { interface EditorDraft {
title: string; title: string;
@@ -25,14 +25,7 @@ interface EditorDraft {
watermarkEnabled: boolean; watermarkEnabled: boolean;
expiresAt: string; expiresAt: string;
coverMediaId: string; coverMediaId: string;
themeMode: GalleryMode;
layout: GalleryLayout;
accent: string; accent: string;
font: 'sans' | 'serif';
studioName: string;
tagline: string;
websiteUrl: string;
instagramUrl: string;
} }
const initialDraft: EditorDraft = { const initialDraft: EditorDraft = {
@@ -45,14 +38,7 @@ const initialDraft: EditorDraft = {
watermarkEnabled: false, watermarkEnabled: false,
expiresAt: '', expiresAt: '',
coverMediaId: '', coverMediaId: '',
themeMode: 'light',
layout: 'editorial',
accent: '#ad695b', accent: '#ad695b',
font: 'serif',
studioName: '',
tagline: '',
websiteUrl: '',
instagramUrl: '',
}; };
function draftFromGallery(gallery: GalleryDetail): EditorDraft { function draftFromGallery(gallery: GalleryDetail): EditorDraft {
@@ -66,14 +52,7 @@ function draftFromGallery(gallery: GalleryDetail): EditorDraft {
watermarkEnabled: gallery.watermarkEnabled, watermarkEnabled: gallery.watermarkEnabled,
expiresAt: gallery.expiresAt ? gallery.expiresAt.slice(0, 10) : '', expiresAt: gallery.expiresAt ? gallery.expiresAt.slice(0, 10) : '',
coverMediaId: gallery.coverMediaId || '', coverMediaId: gallery.coverMediaId || '',
themeMode: gallery.themeConfig.mode || 'light',
layout: gallery.themeConfig.layout || 'editorial',
accent: gallery.themeConfig.accent || '#ad695b', accent: gallery.themeConfig.accent || '#ad695b',
font: gallery.themeConfig.font || 'serif',
studioName: gallery.brandingConfig.studioName || '',
tagline: gallery.brandingConfig.tagline || '',
websiteUrl: gallery.brandingConfig.websiteUrl || '',
instagramUrl: gallery.brandingConfig.instagramUrl || '',
}; };
} }
@@ -170,16 +149,7 @@ export default function GalleryEditorPage() {
expiresAt: draft.expiresAt ? new Date(`${draft.expiresAt}T23:59:59Z`).toISOString() : '', expiresAt: draft.expiresAt ? new Date(`${draft.expiresAt}T23:59:59Z`).toISOString() : '',
coverMediaId: draft.coverMediaId, coverMediaId: draft.coverMediaId,
themeConfig: { themeConfig: {
mode: draft.themeMode,
layout: draft.layout,
accent: draft.accent, accent: draft.accent,
font: draft.font,
},
brandingConfig: {
studioName: draft.studioName,
tagline: draft.tagline,
websiteUrl: draft.websiteUrl,
instagramUrl: draft.instagramUrl,
}, },
...(password ? { password } : {}), ...(password ? { password } : {}),
...(clearPassword ? { clearPassword: true } : {}), ...(clearPassword ? { clearPassword: true } : {}),
@@ -344,11 +314,18 @@ export default function GalleryEditorPage() {
)} )}
</h1> </h1>
</div> </div>
<div className="editor-titlebar__meta">
{gallery?.status === 'published' && ( {gallery?.status === 'published' && (
<span className="editor-live"> <span className="editor-live">
<i /> Live at /g/{gallery.slug} <i /> /g/{gallery.slug}
</span> </span>
)} )}
{gallery && gallery.totalBytes > 0 && (
<span className="editor-storage-size">
{formatBytes(gallery.totalBytes)}
</span>
)}
</div>
</div> </div>
{error && <p className="studio-alert studio-alert--error">{error}</p>} {error && <p className="studio-alert studio-alert--error">{error}</p>}
{notice && <p className="studio-alert studio-alert--success">{notice}</p>} {notice && <p className="studio-alert studio-alert--success">{notice}</p>}
@@ -357,8 +334,8 @@ export default function GalleryEditorPage() {
<div className="editor-maincol"> <div className="editor-maincol">
<section className="editor-section editor-section--first"> <section className="editor-section editor-section--first">
<div className="editor-section__heading"> <div className="editor-section__heading">
<p className="studio-kicker">01 / Gallery_Metadata</p> <p className="studio-kicker">01 / Details</p>
<h2>Gallery_Info</h2> <h2>Gallery Details</h2>
<p>Title and client-facing description.</p> <p>Title and client-facing description.</p>
</div> </div>
<div className="editor-fields editor-fields--two"> <div className="editor-fields editor-fields--two">
@@ -392,10 +369,11 @@ export default function GalleryEditorPage() {
</label> </label>
</section> </section>
<section className="editor-section"> <section className="editor-section">
<div className="editor-section__heading"> <div className="editor-section__heading">
<p className="studio-kicker">02 / Media_Buffer</p> <p className="studio-kicker">02 / Media</p>
<h2>Upload Files</h2> <h2>Upload Files</h2>
<p>Originals stay private in object storage. Previews are prepared in the background.</p>
</div> </div>
{gallery ? ( {gallery ? (
<UploadDropzone <UploadDropzone
@@ -460,8 +438,8 @@ export default function GalleryEditorPage() {
<aside className="editor-aside"> <aside className="editor-aside">
<section className="editor-section editor-section--aside"> <section className="editor-section editor-section--aside">
<div className="editor-section__heading"> <div className="editor-section__heading">
<p className="studio-kicker">03 / Client Control</p> <p className="studio-kicker">03 / Access</p>
<h2>Access Settings</h2> <h2>Client Access</h2>
</div> </div>
<div className="toggle-list"> <div className="toggle-list">
<Toggle <Toggle
@@ -519,34 +497,9 @@ export default function GalleryEditorPage() {
<section className="editor-section editor-section--aside"> <section className="editor-section editor-section--aside">
<div className="editor-section__heading"> <div className="editor-section__heading">
<p className="studio-kicker">04 / Gallery_Config</p> <p className="studio-kicker">04 / Appearance</p>
<h2>Appearance_Module</h2> <h2>Gallery Design</h2>
</div> <p>Customize how the gallery looks to your clients.</p>
<div className="choice-label">Mode</div>
<div className="choice-row">
{(['light', 'dark'] as GalleryMode[]).map((mode) => (
<button
className={draft.themeMode === mode ? 'is-selected' : ''}
type="button"
key={mode}
onClick={() => setField('themeMode', mode)}
>
{mode}
</button>
))}
</div>
<div className="choice-label">Layout</div>
<div className="choice-row choice-row--wrap">
{(['editorial', 'masonry', 'grid'] as GalleryLayout[]).map((layout) => (
<button
className={draft.layout === layout ? 'is-selected' : ''}
type="button"
key={layout}
onClick={() => setField('layout', layout)}
>
{layout}
</button>
))}
</div> </div>
<label className="editor-color-field"> <label className="editor-color-field">
Accent color{' '} Accent color{' '}
@@ -559,63 +512,6 @@ export default function GalleryEditorPage() {
<code>{draft.accent}</code> <code>{draft.accent}</code>
</span> </span>
</label> </label>
<div className="choice-label">Type</div>
<div className="choice-row">
<button
className={draft.font === 'serif' ? 'is-selected' : ''}
type="button"
onClick={() => setField('font', 'serif')}
>
Editorial
</button>
<button
className={draft.font === 'sans' ? 'is-selected' : ''}
type="button"
onClick={() => setField('font', 'sans')}
>
Clean
</button>
</div>
</section>
<section className="editor-section editor-section--aside">
<div className="editor-section__heading">
<p className="studio-kicker">05 / Branding_Config</p>
<h2>Studio_Identity</h2>
</div>
<label className="editor-field-single">
Studio name{' '}
<input
value={draft.studioName}
onChange={(event) => setField('studioName', event.target.value)}
placeholder={user?.name || 'Studio name'}
/>
</label>
<label className="editor-field-single">
Tagline{' '}
<input
value={draft.tagline}
onChange={(event) => setField('tagline', event.target.value)}
placeholder="Studio tagline"
/>
</label>
<label className="editor-field-single">
Website{' '}
<input
type="url"
value={draft.websiteUrl}
onChange={(event) => setField('websiteUrl', event.target.value)}
placeholder="https://noahbianchi.be"
/>
</label>
<label className="editor-field-single">
Instagram{' '}
<input
value={draft.instagramUrl}
onChange={(event) => setField('instagramUrl', event.target.value)}
placeholder="https://instagram.com/noahbianchi"
/>
</label>
</section> </section>
</aside> </aside>
</form> </form>
@@ -674,6 +570,15 @@ function EditorMediaTile({
}) { }) {
const isVideo = item.mimeType.startsWith('video/'); const isVideo = item.mimeType.startsWith('video/');
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
const [showStatus, setShowStatus] = useState(item.processingStatus !== 'READY');
useEffect(() => {
if (item.processingStatus === 'READY') {
const timer = setTimeout(() => setShowStatus(false), 5000);
return () => clearTimeout(timer);
}
setShowStatus(true);
}, [item.processingStatus]);
function handleDragStart(event: React.DragEvent) { function handleDragStart(event: React.DragEvent) {
event.dataTransfer.setData('text/plain', String(index)); event.dataTransfer.setData('text/plain', String(index));
@@ -724,11 +629,11 @@ function EditorMediaTile({
</button> </button>
) : item.previewUrl ? ( ) : item.previewUrl ? (
<img src={item.previewUrl} alt="" loading="lazy" /> <img src={item.previewUrl} alt="" loading="lazy" />
) : ( ) : showStatus ? (
<div className="editor-media-tile__placeholder"> <div className="editor-media-tile__placeholder">
<span>{item.processingStatus}</span> <span>{item.processingStatus}</span>
</div> </div>
)} ) : null}
{isVideo && ( {isVideo && (
<span className="editor-media-tile__film-label"> <span className="editor-media-tile__film-label">
Film {formatDuration(item.durationSeconds)} Film {formatDuration(item.durationSeconds)}
@@ -739,7 +644,7 @@ function EditorMediaTile({
<div className="editor-media-tile__body"> <div className="editor-media-tile__body">
<strong>{item.originalFilename}</strong> <strong>{item.originalFilename}</strong>
<small> <small>
{formatBytes(item.fileSize)} / {item.processingStatus.toLowerCase()} {formatBytes(item.fileSize)}{showStatus ? ` / ${item.processingStatus.toLowerCase()}` : ''}
</small> </small>
</div> </div>
<div className="editor-media-tile__actions"> <div className="editor-media-tile__actions">
+40 -3
View File
@@ -9,6 +9,10 @@ export default function SettingsPage() {
const { user, setUser, signOut } = useAuth(); const { user, setUser, signOut } = useAuth();
const [name, setName] = useState(user?.name || ''); const [name, setName] = useState(user?.name || '');
const [email, setEmail] = useState(user?.email || ''); const [email, setEmail] = useState(user?.email || '');
const [studioName, setStudioName] = useState(user?.studioName || '');
const [tagline, setTagline] = useState(user?.tagline || '');
const [websiteUrl, setWebsiteUrl] = useState(user?.websiteUrl || '');
const [instagramUrl, setInstagramUrl] = useState(user?.instagramUrl || '');
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
@@ -23,6 +27,10 @@ export default function SettingsPage() {
if (!user) return; if (!user) return;
setName(user.name); setName(user.name);
setEmail(user.email); setEmail(user.email);
setStudioName(user.studioName || '');
setTagline(user.tagline || '');
setWebsiteUrl(user.websiteUrl || '');
setInstagramUrl(user.instagramUrl || '');
}, [user]); }, [user]);
async function saveProfile(event: FormEvent<HTMLFormElement>) { async function saveProfile(event: FormEvent<HTMLFormElement>) {
@@ -31,9 +39,9 @@ export default function SettingsPage() {
setProfileStatus(''); setProfileStatus('');
setProfileError(''); setProfileError('');
try { try {
const updated = await updateProfile(name, email); const updated = await updateProfile(name, email, { studioName, tagline, websiteUrl, instagramUrl });
setUser(updated); setUser(updated);
setProfileStatus('Profile saved'); setProfileStatus('Profile saved');
} catch (reason) { } catch (reason) {
setProfileError(reason instanceof Error ? reason.message : 'Profile update failed.'); setProfileError(reason instanceof Error ? reason.message : 'Profile update failed.');
} finally { } finally {
@@ -164,9 +172,38 @@ New password:
</form> </form>
</section> </section>
<section className="settings-panel">
<div className="settings-panel__heading">
<p className="studio-kicker">03 / Branding</p>
<h2>Studio Identity</h2>
<p>Shown in the client gallery header and footer.</p>
</div>
<form className="settings-form" onSubmit={saveProfile}>
<label>
Studio name
<input value={studioName} onChange={(e) => setStudioName(e.target.value)} placeholder="Noah Bianchi" />
</label>
<label>
Tagline
<input value={tagline} onChange={(e) => setTagline(e.target.value)} placeholder="Photography studio" />
</label>
<label>
Website
<input type="url" value={websiteUrl} onChange={(e) => setWebsiteUrl(e.target.value)} placeholder="https://noahbianchi.be" />
</label>
<label>
Instagram
<input value={instagramUrl} onChange={(e) => setInstagramUrl(e.target.value)} placeholder="https://instagram.com/noahbianchi" />
</label>
<button className="settings-button" type="submit" disabled={savingProfile}>
{savingProfile ? 'Saving...' : 'Save Branding'}
</button>
</form>
</section>
<section className="settings-panel settings-panel--session"> <section className="settings-panel settings-panel--session">
<div className="settings-panel__heading"> <div className="settings-panel__heading">
<p className="studio-kicker">03 / Session</p> <p className="studio-kicker">04 / Session</p>
<h2>Session Status</h2> <h2>Session Status</h2>
</div> </div>
<div className="settings-session-row"> <div className="settings-session-row">
+109 -51
View File
@@ -1690,6 +1690,11 @@ a {
} }
.editor-titlebar { .editor-titlebar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: clamp(20px, 3vw, 40px) clamp(24px, 4vw, 48px) 0; padding: clamp(20px, 3vw, 40px) clamp(24px, 4vw, 48px) 0;
} }
@@ -1745,6 +1750,14 @@ a {
line-height: 1.5; line-height: 1.5;
} }
.editor-section__footnote {
font-size: 10px;
color: var(--fg-muted);
margin: -8px 0 16px;
line-height: 1.4;
font-style: italic;
}
.editor-fields { .editor-fields {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1838,6 +1851,13 @@ input[type='color'] {
color: var(--fg-muted); color: var(--fg-muted);
} }
.editor-titlebar__meta {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.editor-live { .editor-live {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1849,6 +1869,15 @@ input[type='color'] {
color: var(--success); color: var(--success);
} }
.editor-storage-size {
font-size: 10px;
font-weight: 500;
color: var(--fg-muted);
padding: 4px 10px;
border: 1px solid var(--border);
white-space: nowrap;
}
.editor-button { .editor-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -2345,25 +2374,30 @@ input[type='color'] {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 10px 0; padding: 12px 0;
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
} }
.toggle-line > span { .toggle-line > span {
font-size: 12px; display: flex;
color: var(--fg); flex-direction: column;
font-weight: 500; gap: 1px;
flex: 1; flex: 1;
min-width: 0;
} }
.toggle-line strong { .toggle-line strong {
font-weight: 600; font-weight: 600;
font-size: 11px; font-size: 11px;
color: var(--fg);
} }
.toggle-line small { .toggle-line small {
font-size: 10px; font-size: 10px;
color: var(--fg-muted); color: var(--fg-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.toggle-line input[type='checkbox'] { .toggle-line input[type='checkbox'] {
@@ -2403,6 +2437,7 @@ input[type='color'] {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
margin-bottom: 10px;
} }
.choice-row--wrap { .choice-row--wrap {
@@ -2418,6 +2453,8 @@ input[type='color'] {
text-transform: uppercase; text-transform: uppercase;
color: var(--fg-muted); color: var(--fg-muted);
transition: border-color var(--transition), color var(--transition), background var(--transition); transition: border-color var(--transition), color var(--transition), background var(--transition);
cursor: pointer;
background: none;
} }
.choice-row button:hover { .choice-row button:hover {
@@ -2446,31 +2483,51 @@ input[type='color'] {
} }
.choice-label { .choice-label {
display: flex; font-size: 9px;
align-items: center; font-weight: 600;
gap: 8px; letter-spacing: 0.08em;
padding: 6px 12px; text-transform: uppercase;
border: 1px solid var(--border); color: var(--fg-muted);
font-size: 11px; margin: 12px 0 6px;
color: var(--fg-secondary);
cursor: pointer;
transition: border-color var(--transition);
} }
.choice-label:hover { .choice-label:first-of-type {
border-color: var(--fg-secondary); margin-top: 0;
} }
.editor-color-field { .editor-color-field {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; flex-wrap: wrap;
gap: 8px;
margin: 10px 0;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-muted);
} }
.editor-color-field > span { .editor-color-field > span {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 10px; font-size: 10px;
font-weight: 500; font-weight: 500;
color: var(--fg-muted); color: var(--fg-muted);
text-transform: none;
letter-spacing: 0;
}
.editor-color-field input[type='color'] {
width: 30px !important;
min-width: 30px;
height: 30px;
padding: 2px !important;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
background: none;
} }
input[type='url'] { input[type='url'] {
@@ -2709,41 +2766,44 @@ input[type='url'] {
.client-media-grid { .client-media-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1px; gap: 6px;
padding: 1px 0; padding: 6px 0;
} }
.client-media-card { .client-media-card {
break-inside: avoid;
position: relative; position: relative;
} }
.client-media-card__image { .client-media-card__image {
position: relative; position: relative;
overflow: hidden; display: flex;
align-items: center;
justify-content: center;
background: var(--surface); background: var(--surface);
cursor: pointer; cursor: pointer;
width: 100%; width: 100%;
padding: 0; padding: 0;
border: 0; border: 0;
display: block; aspect-ratio: 4 / 3;
overflow: hidden;
} }
.client-media-card__image img { .client-media-card__image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 700ms cubic-bezier(0.22, 1, 0.36, 1);
display: block; display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
transition: transform 700ms cubic-bezier(0.22, 1, 0.36, 1);
} }
.client-media-card__image:hover img { .client-media-card__image:hover img {
transform: scale(1.03); transform: scale(1.03);
} }
.client-media-card:nth-child(7n + 1) .client-media-card__image { .client-media-card:nth-child(5n + 1) .client-media-card__image,
aspect-ratio: 1.5; .client-media-card:nth-child(7n + 3) .client-media-card__image {
aspect-ratio: 4 / 3;
} }
.client-media-card__open { .client-media-card__open {
@@ -2770,21 +2830,22 @@ input[type='url'] {
.client-media-card__video-wrap { .client-media-card__video-wrap {
position: relative; position: relative;
aspect-ratio: 1; display: flex;
align-items: center;
justify-content: center;
background: var(--surface); background: var(--surface);
display: block;
width: 100%; width: 100%;
padding: 0; padding: 0;
border: 0; border: 0;
cursor: pointer; cursor: pointer;
overflow: hidden; aspect-ratio: 4 / 3;
} }
.client-media-card__video-wrap video { .client-media-card__video-wrap video {
display: block; display: block;
width: 100%; max-width: 100%;
height: 100%; max-height: 100%;
object-fit: cover; object-fit: contain;
background: var(--surface); background: var(--surface);
pointer-events: none; pointer-events: none;
} }
@@ -3456,22 +3517,19 @@ input[type='url'] {
text-transform: uppercase; text-transform: uppercase;
} }
/* Gallery editorial variant */ /* Gallery layout variants */
.client-gallery--editorial .client-media-card:nth-child(5n+3) { .client-gallery--grid .client-media-card__image,
grid-column: span 2; .client-gallery--grid .client-media-card__video-wrap {
grid-row: span 2; aspect-ratio: 1;
} }
.client-gallery--grid .client-media-card { .client-gallery--grid .client-media-card:nth-child(5n + 1) .client-media-card__image,
margin-bottom: 0; .client-gallery--grid .client-media-card:nth-child(5n + 1) .client-media-card__video-wrap,
} .client-gallery--grid .client-media-card:nth-child(7n + 3) .client-media-card__image,
.client-gallery--grid .client-media-card:nth-child(7n + 3) .client-media-card__video-wrap {
.client-gallery--masonry .client-media-card { grid-column: span 1;
margin-bottom: 0; grid-row: span 1;
} aspect-ratio: 1;
.client-gallery--masonry .client-media-card:nth-child(3n+2) {
margin-top: 40px;
} }
/* Preview mode */ /* Preview mode */
@@ -3732,9 +3790,9 @@ input[type='url'] {
} }
.settings-layout { .settings-layout {
display: grid; display: flex;
grid-template-columns: 1fr 1fr; flex-direction: column;
gap: 32px; gap: 24px;
} }
.settings-panel { .settings-panel {
+4
View File
@@ -0,0 +1,4 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS studio_name TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS tagline TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS website_url TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS instagram_url TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,4 @@
ALTER TABLE users ADD COLUMN studio_name TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN tagline TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN website_url TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN instagram_url TEXT NOT NULL DEFAULT '';