fix more stuff
This commit is contained in:
@@ -38,6 +38,10 @@ type credentialsRequest struct {
|
||||
type profileRequest struct {
|
||||
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"})
|
||||
|
||||
@@ -6,6 +6,10 @@ type User struct {
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<User> {
|
||||
@@ -81,10 +85,10 @@ export async function logout(): Promise<void> {
|
||||
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', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name, email }),
|
||||
body: JSON.stringify({ name, email, ...branding }),
|
||||
});
|
||||
return response.user;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="editor-titlebar__meta">
|
||||
{gallery?.status === 'published' && (
|
||||
<span className="editor-live">
|
||||
<i /> Live at /g/{gallery.slug}
|
||||
<i /> /g/{gallery.slug}
|
||||
</span>
|
||||
)}
|
||||
{gallery && gallery.totalBytes > 0 && (
|
||||
<span className="editor-storage-size">
|
||||
{formatBytes(gallery.totalBytes)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="studio-alert studio-alert--error">{error}</p>}
|
||||
{notice && <p className="studio-alert studio-alert--success">{notice}</p>}
|
||||
@@ -357,8 +334,8 @@ export default function GalleryEditorPage() {
|
||||
<div className="editor-maincol">
|
||||
<section className="editor-section editor-section--first">
|
||||
<div className="editor-section__heading">
|
||||
<p className="studio-kicker">01 / Gallery_Metadata</p>
|
||||
<h2>Gallery_Info</h2>
|
||||
<p className="studio-kicker">01 / Details</p>
|
||||
<h2>Gallery Details</h2>
|
||||
<p>Title and client-facing description.</p>
|
||||
</div>
|
||||
<div className="editor-fields editor-fields--two">
|
||||
@@ -394,8 +371,9 @@ export default function GalleryEditorPage() {
|
||||
|
||||
<section className="editor-section">
|
||||
<div className="editor-section__heading">
|
||||
<p className="studio-kicker">02 / Media_Buffer</p>
|
||||
<p className="studio-kicker">02 / Media</p>
|
||||
<h2>Upload Files</h2>
|
||||
<p>Originals stay private in object storage. Previews are prepared in the background.</p>
|
||||
</div>
|
||||
{gallery ? (
|
||||
<UploadDropzone
|
||||
@@ -460,8 +438,8 @@ export default function GalleryEditorPage() {
|
||||
<aside className="editor-aside">
|
||||
<section className="editor-section editor-section--aside">
|
||||
<div className="editor-section__heading">
|
||||
<p className="studio-kicker">03 / Client Control</p>
|
||||
<h2>Access Settings</h2>
|
||||
<p className="studio-kicker">03 / Access</p>
|
||||
<h2>Client Access</h2>
|
||||
</div>
|
||||
<div className="toggle-list">
|
||||
<Toggle
|
||||
@@ -519,34 +497,9 @@ export default function GalleryEditorPage() {
|
||||
|
||||
<section className="editor-section editor-section--aside">
|
||||
<div className="editor-section__heading">
|
||||
<p className="studio-kicker">04 / Gallery_Config</p>
|
||||
<h2>Appearance_Module</h2>
|
||||
</div>
|
||||
<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>
|
||||
))}
|
||||
<p className="studio-kicker">04 / Appearance</p>
|
||||
<h2>Gallery Design</h2>
|
||||
<p>Customize how the gallery looks to your clients.</p>
|
||||
</div>
|
||||
<label className="editor-color-field">
|
||||
Accent color{' '}
|
||||
@@ -559,63 +512,6 @@ export default function GalleryEditorPage() {
|
||||
<code>{draft.accent}</code>
|
||||
</span>
|
||||
</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>
|
||||
</aside>
|
||||
</form>
|
||||
@@ -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({
|
||||
</button>
|
||||
) : item.previewUrl ? (
|
||||
<img src={item.previewUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
) : showStatus ? (
|
||||
<div className="editor-media-tile__placeholder">
|
||||
<span>{item.processingStatus}</span>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
{isVideo && (
|
||||
<span className="editor-media-tile__film-label">
|
||||
Film {formatDuration(item.durationSeconds)}
|
||||
@@ -739,7 +644,7 @@ function EditorMediaTile({
|
||||
<div className="editor-media-tile__body">
|
||||
<strong>{item.originalFilename}</strong>
|
||||
<small>
|
||||
{formatBytes(item.fileSize)} / {item.processingStatus.toLowerCase()}
|
||||
{formatBytes(item.fileSize)}{showStatus ? ` / ${item.processingStatus.toLowerCase()}` : ''}
|
||||
</small>
|
||||
</div>
|
||||
<div className="editor-media-tile__actions">
|
||||
|
||||
@@ -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<HTMLFormElement>) {
|
||||
@@ -31,7 +39,7 @@ 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) {
|
||||
@@ -164,9 +172,38 @@ New password:
|
||||
</form>
|
||||
</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">
|
||||
<div className="settings-panel__heading">
|
||||
<p className="studio-kicker">03 / Session</p>
|
||||
<p className="studio-kicker">04 / Session</p>
|
||||
<h2>Session Status</h2>
|
||||
</div>
|
||||
<div className="settings-session-row">
|
||||
|
||||
+109
-51
@@ -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 {
|
||||
|
||||
@@ -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 '';
|
||||
Reference in New Issue
Block a user