From 581f5444fb490ce2dbf2d87152bbf37416b8ab78 Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Mon, 24 Aug 2026 22:51:07 +0200 Subject: [PATCH] fix more stuff --- backend/internal/auth/handler.go | 10 +- backend/internal/auth/models.go | 10 +- backend/internal/auth/repository.go | 20 +-- backend/internal/auth/service.go | 4 +- frontend/src/lib/api.ts | 8 +- .../src/pages/dashboard/GalleryEditorPage.tsx | 165 ++++-------------- frontend/src/pages/dashboard/SettingsPage.tsx | 47 ++++- frontend/src/styles/index.css | 160 +++++++++++------ migrations/004_studio_branding.sql | 4 + migrations/sqlite/004_studio_branding.sql | 4 + 10 files changed, 226 insertions(+), 206 deletions(-) create mode 100644 migrations/004_studio_branding.sql create mode 100644 migrations/sqlite/004_studio_branding.sql diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go index 4027395..a7bf039 100644 --- a/backend/internal/auth/handler.go +++ b/backend/internal/auth/handler.go @@ -36,8 +36,12 @@ type credentialsRequest struct { } type profileRequest struct { - Email string `json:"email"` - Name string `json:"name"` + Email string `json:"email"` + Name string `json:"name"` + StudioName string `json:"studioName"` + Tagline string `json:"tagline"` + WebsiteURL string `json:"websiteUrl"` + InstagramURL string `json:"instagramUrl"` } type passwordRequest struct { @@ -151,7 +155,7 @@ func (h *Handler) UpdateMe(c *gin.Context) { if !decodeJSON(c, &request) { 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 errors.Is(err, ErrEmailTaken) { writeJSON(c, http.StatusConflict, map[string]string{"error": "email is already registered"}) diff --git a/backend/internal/auth/models.go b/backend/internal/auth/models.go index 673f397..d3ed648 100644 --- a/backend/internal/auth/models.go +++ b/backend/internal/auth/models.go @@ -3,9 +3,13 @@ package auth import "github.com/google/uuid" type User struct { - ID uuid.UUID `json:"id"` - Email string `json:"email"` - Name string `json:"name"` + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + StudioName string `json:"studioName"` + Tagline string `json:"tagline"` + WebsiteURL string `json:"websiteUrl"` + InstagramURL string `json:"instagramUrl"` } type storedUser struct { diff --git a/backend/internal/auth/repository.go b/backend/internal/auth/repository.go index b05338d..b500e60 100644 --- a/backend/internal/auth/repository.go +++ b/backend/internal/auth/repository.go @@ -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) { var user storedUser 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 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) { 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) { var user User err := r.db.QueryRowContext(ctx, ` - SELECT id, email, name + SELECT id, email, name, studio_name, tagline, website_url, instagram_url FROM users 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) { 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) { var user storedUser 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 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) { return storedUser{}, sql.ErrNoRows } @@ -83,12 +83,12 @@ func (r *Repository) FindByIDWithPassword(ctx context.Context, id uuid.UUID) (st 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, ` UPDATE users - SET email = $1, name = $2, updated_at = CURRENT_TIMESTAMP - WHERE id = $3 - `, strings.ToLower(strings.TrimSpace(email)), strings.TrimSpace(name), id) + SET email = $1, name = $2, studio_name = $3, tagline = $4, website_url = $5, instagram_url = $6, updated_at = CURRENT_TIMESTAMP + WHERE id = $7 + `, strings.ToLower(strings.TrimSpace(email)), strings.TrimSpace(name), studioName, tagline, websiteURL, instagramURL, id) if err != nil { if strings.Contains(strings.ToLower(err.Error()), "unique") { return User{}, ErrEmailTaken diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go index 11ca0f2..f9d6b4a 100644 --- a/backend/internal/auth/service.go +++ b/backend/internal/auth/service.go @@ -80,7 +80,7 @@ func (s *Service) Login(ctx context.Context, email, password string) (User, erro 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)) name = strings.TrimSpace(name) 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 { 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 { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 038092a..ebeb58e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -54,6 +54,10 @@ export interface User { id: string; email: string; name: string; + studioName?: string; + tagline?: string; + websiteUrl?: string; + instagramUrl?: string; } export async function getMe(): Promise { @@ -81,10 +85,10 @@ export async function logout(): Promise { await request('/api/auth/logout', { method: 'POST' }); } -export async function updateProfile(name: string, email: string): Promise { +export async function updateProfile(name: string, email: string, branding?: { studioName?: string; tagline?: string; websiteUrl?: string; instagramUrl?: string }): Promise { const response = await request<{ user: User }>('/api/auth/me', { method: 'PATCH', - body: JSON.stringify({ name, email }), + body: JSON.stringify({ name, email, ...branding }), }); return response.user; } diff --git a/frontend/src/pages/dashboard/GalleryEditorPage.tsx b/frontend/src/pages/dashboard/GalleryEditorPage.tsx index fa1dd03..d7f43c0 100644 --- a/frontend/src/pages/dashboard/GalleryEditorPage.tsx +++ b/frontend/src/pages/dashboard/GalleryEditorPage.tsx @@ -13,7 +13,7 @@ import { updateMediaOrder, } from '../../lib/api'; import { formatBytes, formatDuration } from '../../lib/format'; -import type { GalleryDetail, GalleryLayout, GalleryMode, MediaItem } from '../../types/gallery'; +import type { GalleryDetail, MediaItem } from '../../types/gallery'; interface EditorDraft { title: string; @@ -25,14 +25,7 @@ interface EditorDraft { watermarkEnabled: boolean; expiresAt: string; coverMediaId: string; - themeMode: GalleryMode; - layout: GalleryLayout; accent: string; - font: 'sans' | 'serif'; - studioName: string; - tagline: string; - websiteUrl: string; - instagramUrl: string; } const initialDraft: EditorDraft = { @@ -45,14 +38,7 @@ const initialDraft: EditorDraft = { watermarkEnabled: false, expiresAt: '', coverMediaId: '', - themeMode: 'light', - layout: 'editorial', accent: '#ad695b', - font: 'serif', - studioName: '', - tagline: '', - websiteUrl: '', - instagramUrl: '', }; function draftFromGallery(gallery: GalleryDetail): EditorDraft { @@ -66,14 +52,7 @@ function draftFromGallery(gallery: GalleryDetail): EditorDraft { watermarkEnabled: gallery.watermarkEnabled, expiresAt: gallery.expiresAt ? gallery.expiresAt.slice(0, 10) : '', coverMediaId: gallery.coverMediaId || '', - themeMode: gallery.themeConfig.mode || 'light', - layout: gallery.themeConfig.layout || 'editorial', 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() : '', coverMediaId: draft.coverMediaId, themeConfig: { - mode: draft.themeMode, - layout: draft.layout, accent: draft.accent, - font: draft.font, - }, - brandingConfig: { - studioName: draft.studioName, - tagline: draft.tagline, - websiteUrl: draft.websiteUrl, - instagramUrl: draft.instagramUrl, }, ...(password ? { password } : {}), ...(clearPassword ? { clearPassword: true } : {}), @@ -344,11 +314,18 @@ export default function GalleryEditorPage() { )} - {gallery?.status === 'published' && ( - - Live at /g/{gallery.slug} - - )} +
+ {gallery?.status === 'published' && ( + + /g/{gallery.slug} + + )} + {gallery && gallery.totalBytes > 0 && ( + + {formatBytes(gallery.totalBytes)} + + )} +
{error &&

{error}

} {notice &&

{notice}

} @@ -357,8 +334,8 @@ export default function GalleryEditorPage() {
-

01 / Gallery_Metadata

-

Gallery_Info

+

01 / Details

+

Gallery Details

Title and client-facing description.

@@ -392,10 +369,11 @@ export default function GalleryEditorPage() {
-
+
-

02 / Media_Buffer

+

02 / Media

Upload Files

+

Originals stay private in object storage. Previews are prepared in the background.

{gallery ? (
-

03 / Client Control

-

Access Settings

+

03 / Access

+

Client Access

-

04 / Gallery_Config

-

Appearance_Module

-
-
Mode
-
- {(['light', 'dark'] as GalleryMode[]).map((mode) => ( - - ))} -
-
Layout
-
- {(['editorial', 'masonry', 'grid'] as GalleryLayout[]).map((layout) => ( - - ))} +

04 / Appearance

+

Gallery Design

+

Customize how the gallery looks to your clients.

-
Type
-
- - -
-
- -
-
-

05 / Branding_Config

-

Studio_Identity

-
- - - -
@@ -674,6 +570,15 @@ function EditorMediaTile({ }) { const isVideo = item.mimeType.startsWith('video/'); 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) { event.dataTransfer.setData('text/plain', String(index)); @@ -724,11 +629,11 @@ function EditorMediaTile({ ) : item.previewUrl ? ( - ) : ( + ) : showStatus ? (
{item.processingStatus}
- )} + ) : null} {isVideo && ( Film {formatDuration(item.durationSeconds)} @@ -739,7 +644,7 @@ function EditorMediaTile({
{item.originalFilename} - {formatBytes(item.fileSize)} / {item.processingStatus.toLowerCase()} + {formatBytes(item.fileSize)}{showStatus ? ` / ${item.processingStatus.toLowerCase()}` : ''}
diff --git a/frontend/src/pages/dashboard/SettingsPage.tsx b/frontend/src/pages/dashboard/SettingsPage.tsx index 31abca4..fec14c7 100644 --- a/frontend/src/pages/dashboard/SettingsPage.tsx +++ b/frontend/src/pages/dashboard/SettingsPage.tsx @@ -9,6 +9,10 @@ export default function SettingsPage() { const { user, setUser, signOut } = useAuth(); const [name, setName] = useState(user?.name || ''); 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 [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); @@ -23,6 +27,10 @@ export default function SettingsPage() { if (!user) return; setName(user.name); setEmail(user.email); + setStudioName(user.studioName || ''); + setTagline(user.tagline || ''); + setWebsiteUrl(user.websiteUrl || ''); + setInstagramUrl(user.instagramUrl || ''); }, [user]); async function saveProfile(event: FormEvent) { @@ -31,11 +39,11 @@ export default function SettingsPage() { setProfileStatus(''); setProfileError(''); try { - const updated = await updateProfile(name, email); + const updated = await updateProfile(name, email, { studioName, tagline, websiteUrl, instagramUrl }); setUser(updated); -setProfileStatus('Profile saved'); - } catch (reason) { - setProfileError(reason instanceof Error ? reason.message : 'Profile update failed.'); + setProfileStatus('Profile saved'); + } catch (reason) { + setProfileError(reason instanceof Error ? reason.message : 'Profile update failed.'); } finally { setSavingProfile(false); } @@ -164,9 +172,38 @@ New password:
+
+
+

03 / Branding

+

Studio Identity

+

Shown in the client gallery header and footer.

+
+
+ + + + + +
+
+
-

03 / Session

+

04 / Session

Session Status

diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 53f9417..027a7b9 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -1690,6 +1690,11 @@ a { } .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; } @@ -1745,6 +1750,14 @@ a { 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 { display: flex; flex-direction: column; @@ -1838,6 +1851,13 @@ input[type='color'] { color: var(--fg-muted); } +.editor-titlebar__meta { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + .editor-live { display: flex; align-items: center; @@ -1849,6 +1869,15 @@ input[type='color'] { 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 { display: inline-flex; align-items: center; @@ -2345,25 +2374,30 @@ input[type='color'] { display: flex; align-items: center; gap: 12px; - padding: 10px 0; + padding: 12px 0; border-bottom: 1px solid var(--border-subtle); } .toggle-line > span { - font-size: 12px; - color: var(--fg); - font-weight: 500; + display: flex; + flex-direction: column; + gap: 1px; flex: 1; + min-width: 0; } .toggle-line strong { font-weight: 600; font-size: 11px; + color: var(--fg); } .toggle-line small { font-size: 10px; color: var(--fg-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .toggle-line input[type='checkbox'] { @@ -2403,6 +2437,7 @@ input[type='color'] { display: flex; flex-wrap: wrap; gap: 6px; + margin-bottom: 10px; } .choice-row--wrap { @@ -2418,6 +2453,8 @@ input[type='color'] { text-transform: uppercase; color: var(--fg-muted); transition: border-color var(--transition), color var(--transition), background var(--transition); + cursor: pointer; + background: none; } .choice-row button:hover { @@ -2446,31 +2483,51 @@ input[type='color'] { } .choice-label { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 12px; - border: 1px solid var(--border); - font-size: 11px; - color: var(--fg-secondary); - cursor: pointer; - transition: border-color var(--transition); + font-size: 9px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-muted); + margin: 12px 0 6px; } -.choice-label:hover { - border-color: var(--fg-secondary); +.choice-label:first-of-type { + margin-top: 0; } .editor-color-field { display: flex; 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 { + display: inline-flex; + align-items: center; + gap: 6px; font-size: 10px; font-weight: 500; 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'] { @@ -2709,41 +2766,44 @@ input[type='url'] { .client-media-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: 1px; - padding: 1px 0; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 6px; + padding: 6px 0; } .client-media-card { - break-inside: avoid; position: relative; } .client-media-card__image { position: relative; - overflow: hidden; + display: flex; + align-items: center; + justify-content: center; background: var(--surface); cursor: pointer; width: 100%; padding: 0; border: 0; - display: block; + aspect-ratio: 4 / 3; + overflow: hidden; } .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; + 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 { transform: scale(1.03); } -.client-media-card:nth-child(7n + 1) .client-media-card__image { - aspect-ratio: 1.5; +.client-media-card:nth-child(5n + 1) .client-media-card__image, +.client-media-card:nth-child(7n + 3) .client-media-card__image { + aspect-ratio: 4 / 3; } .client-media-card__open { @@ -2770,21 +2830,22 @@ input[type='url'] { .client-media-card__video-wrap { position: relative; - aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; background: var(--surface); - display: block; width: 100%; padding: 0; border: 0; cursor: pointer; - overflow: hidden; + aspect-ratio: 4 / 3; } .client-media-card__video-wrap video { display: block; - width: 100%; - height: 100%; - object-fit: cover; + max-width: 100%; + max-height: 100%; + object-fit: contain; background: var(--surface); pointer-events: none; } @@ -3456,22 +3517,19 @@ input[type='url'] { text-transform: uppercase; } -/* Gallery editorial variant */ -.client-gallery--editorial .client-media-card:nth-child(5n+3) { - grid-column: span 2; - grid-row: span 2; +/* Gallery layout variants */ +.client-gallery--grid .client-media-card__image, +.client-gallery--grid .client-media-card__video-wrap { + aspect-ratio: 1; } -.client-gallery--grid .client-media-card { - margin-bottom: 0; -} - -.client-gallery--masonry .client-media-card { - margin-bottom: 0; -} - -.client-gallery--masonry .client-media-card:nth-child(3n+2) { - margin-top: 40px; +.client-gallery--grid .client-media-card:nth-child(5n + 1) .client-media-card__image, +.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 { + grid-column: span 1; + grid-row: span 1; + aspect-ratio: 1; } /* Preview mode */ @@ -3732,9 +3790,9 @@ input[type='url'] { } .settings-layout { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 32px; + display: flex; + flex-direction: column; + gap: 24px; } .settings-panel { diff --git a/migrations/004_studio_branding.sql b/migrations/004_studio_branding.sql new file mode 100644 index 0000000..af4605e --- /dev/null +++ b/migrations/004_studio_branding.sql @@ -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 ''; \ No newline at end of file diff --git a/migrations/sqlite/004_studio_branding.sql b/migrations/sqlite/004_studio_branding.sql new file mode 100644 index 0000000..e7bf69a --- /dev/null +++ b/migrations/sqlite/004_studio_branding.sql @@ -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 ''; \ No newline at end of file