nimuli/resources/views/livewire/pages/bio/edit.blade.php

1091 lines
68 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

{{--
Bio page editor all x-html targets are built from Alpine component state only
(no unescaped server data injected). renderBlocksHtml / renderSocialsHtml compose
strings from state.blocks and state.socials, which come from $initialState (PHP array
encoded via @json). User-entered text is output via textContent-equivalent bindings
(x-text) wherever XSS matters; x-html is used only for the live phone preview where
we build structured HTML from trusted state properties (urls, titles, etc.).
--}}
<div x-data='bioEditor(@json($initialState))' class="bio-editor-root">
{{-- =================== Topbar =================== --}}
<div class="bio-topbar px-6 py-3 z-20 bg-s1/95 backdrop-blur-sm border-b border-b1 flex items-center gap-3" style="flex-shrink:0">
<nav class="crumbs">
<span>Link-in-Bio</span>
<span class="sep">/</span>
<span class="here">Editor</span>
</nav>
<div class="url-badge">
<span class="dot"></span>
<span x-text="displayUrl"></span>
</div>
<div style="flex:1"></div>
<a href="{{ route('w.bio.preview', [$workspaceUlid, $bioUlid]) }}" target="_blank" class="btn ghost">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
Preview
</a>
<button class="btn" @click="save()">Save draft</button>
<button class="btn primary" @click="publish()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span x-text="state.isPublished ? 'Published' : 'Publish'"></span>
</button>
</div>
{{-- =================== Editor + Preview Grid =================== --}}
<div class="editor-layout">
{{-- ============ Editor (left) ============ --}}
<section class="editor">
<h1 class="page-title">Link-in-Bio</h1>
<p class="page-sub">Build a fully personalized landing page. Everything updates live in the preview on the right.</p>
{{-- Section 1: Profile --}}
<div class="section">
<div class="section-head">
<span class="num">1</span>
<h3>Profile</h3>
<span class="hint">How you appear on top</span>
</div>
<div class="section-body">
{{-- Avatar + Name/Handle --}}
<div class="avatar-row">
<div style="position:relative;display:inline-block;flex-shrink:0">
<label class="avatar-up" for="avatarInput">
<img x-show="state.profile.avatar" :src="state.profile.avatar" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%">
<svg x-show="!state.profile.avatar" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
<div class="av-overlay">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
</div>
<input id="avatarInput" type="file" accept="image/*" hidden
@change="if($event.target.files[0]) uploadAvatar($event.target.files[0])">
</label>
<button x-show="state.profile.avatar" @click.prevent="state.profile.avatar = null"
style="position:absolute;top:-5px;right:-5px;width:20px;height:20px;border-radius:50%;background:var(--color-red);color:#fff;display:grid;place-items:center;font-size:12px;line-height:1;border:2px solid var(--color-s2)">×</button>
</div>
<div class="field-row">
<div class="field">
<label class="label" for="displayName">Display name</label>
<input class="input" id="displayName" type="text"
x-model="state.profile.displayName"
placeholder="Your name">
</div>
<div class="field">
<label class="label" for="handle">Handle</label>
<div class="input-prefix">
<span class="prefix">@</span>
<input class="input" id="handle" type="text"
x-model="state.profile.handle"
placeholder="yourhandle">
</div>
</div>
</div>
</div>
{{-- Bio textarea --}}
<div class="field">
<label class="label" for="bioText">Short bio</label>
<textarea class="textarea" id="bioText" maxlength="140"
x-model="state.profile.bio"
placeholder="Tell visitors who you are…"></textarea>
<div class="char-count"><span x-text="(state.profile.bio || '').length"></span>/140</div>
</div>
{{-- Verified toggle --}}
<div class="toggle-row" style="cursor:pointer"
@click="state.profile.verified = !state.profile.verified">
<div class="t-text">
<div class="t-title">Show verified badge</div>
<div class="t-desc">Adds a blue check next to your display name.</div>
</div>
<div class="switch" :class="{'is-on': state.profile.verified}"></div>
</div>
</div>
</div>
{{-- Section 2: Theme --}}
<div class="section">
<div class="section-head">
<span class="num">2</span>
<h3>Theme</h3>
<span class="hint">Background, buttons, fonts</span>
</div>
<div class="section-body">
{{-- Preset themes --}}
<div class="field">
<span class="label">Preset themes</span>
<div class="theme-presets">
<button data-theme="midnight"
:class="{'is-active': state.theme.bg1==='#0f0a2e' && state.theme.bg2==='#6b1b9a'}"
@click="applyThemePreset('midnight')"
style="background: linear-gradient(160deg, #0f0a2e, #2c0e57 60%, #6b1b9a)"></button>
<button data-theme="sunset"
:class="{'is-active': state.theme.bg1==='#ff8a3d' && state.theme.bg2==='#b91c5c'}"
@click="applyThemePreset('sunset')"
style="background: linear-gradient(160deg, #ff8a3d, #ff3d77, #b91c5c)"></button>
<button data-theme="forest"
:class="{'is-active': state.theme.bg1==='#064e3b' && state.theme.bg2==='#0f766e'}"
@click="applyThemePreset('forest')"
style="background: linear-gradient(160deg, #064e3b, #065f46, #0f766e)"></button>
<button data-theme="paper"
:class="{'is-active': state.theme.bg1==='#faf8f3' && !state.theme.dark}"
@click="applyThemePreset('paper')"
style="background: #faf8f3; box-shadow:inset 0 0 0 1px #2a2a36"></button>
<button data-theme="ocean"
:class="{'is-active': state.theme.bg1==='#0c4a6e' && state.theme.bg2==='#0ea5e9'}"
@click="applyThemePreset('ocean')"
style="background: linear-gradient(160deg, #0c4a6e, #0369a1, #0ea5e9)"></button>
<button data-theme="candy"
:class="{'is-active': state.theme.bg1==='#fce7f3' && state.theme.bg2==='#f9a8d4'}"
@click="applyThemePreset('candy')"
style="background: linear-gradient(160deg, #fce7f3, #fbcfe8, #f9a8d4)"></button>
</div>
</div>
{{-- Background type --}}
<div class="field">
<span class="label">Background</span>
<div class="chip-group">
<div class="chip" :class="{'is-active': state.theme.bgType==='gradient'}"
@click="state.theme.bgType='gradient'">Gradient</div>
<div class="chip" :class="{'is-active': state.theme.bgType==='solid'}"
@click="state.theme.bgType='solid'">Solid</div>
<div class="chip" :class="{'is-active': state.theme.bgType==='image'}"
@click="state.theme.bgType='image'">Image</div>
</div>
</div>
{{-- Color rows --}}
<div class="field-row" x-show="state.theme.bgType !== 'image'">
<div class="field">
<span class="label">Color 1</span>
<div class="color-row">
<label class="color-swatch" :style="'background:'+state.theme.bg1">
<input type="color" :value="state.theme.bg1"
@input="syncColor('bg1', $event.target.value)">
</label>
<input class="input" type="text" :value="state.theme.bg1"
@input="if(/^#[0-9a-f]{6}$/i.test($event.target.value)) syncColor('bg1', $event.target.value)">
</div>
</div>
<div class="field" x-show="state.theme.bgType === 'gradient'">
<span class="label">Color 2</span>
<div class="color-row">
<label class="color-swatch" :style="'background:'+state.theme.bg2">
<input type="color" :value="state.theme.bg2"
@input="syncColor('bg2', $event.target.value)">
</label>
<input class="input" type="text" :value="state.theme.bg2"
@input="if(/^#[0-9a-f]{6}$/i.test($event.target.value)) syncColor('bg2', $event.target.value)">
</div>
</div>
</div>
{{-- Background image dropzone --}}
<div x-show="state.theme.bgType === 'image'">
<label class="bg-dropzone" for="bgImageInput">
<div class="bg-thumb">
<template x-if="!state.theme.bgImage">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
</template>
<template x-if="state.theme.bgImage">
<img :src="state.theme.bgImage" alt="" style="width:100%;height:100%;object-fit:cover">
</template>
</div>
<div class="bg-info">
<div class="t" x-text="state.theme.bgImage ? 'Change background image' : 'Upload background image'"></div>
<div class="s">JPG, PNG · compressed to 1080px · up to 8 MB</div>
</div>
<input id="bgImageInput" type="file" accept="image/*" hidden
@change="if($event.target.files[0]) uploadBgImage($event.target.files[0])">
</label>
<button x-show="state.theme.bgImage"
@click="state.theme.bgImage = null"
class="btn ghost" style="width:100%;margin-top:6px;justify-content:center;color:var(--color-red)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"/></svg>
Remove image
</button>
</div>
{{-- Dark mode toggle --}}
<div class="toggle-row" style="cursor:pointer"
@click="state.theme.dark = !state.theme.dark">
<div class="t-text">
<div class="t-title">Light text on dark background</div>
<div class="t-desc">Switches text color for readability on dark themes.</div>
</div>
<div class="switch" :class="{'is-on': state.theme.dark}"></div>
</div>
{{-- Button style --}}
<div class="field">
<span class="label">Button style</span>
<div class="btn-style-grid">
<button class="btn-style-opt" data-style="rounded"
:class="{'is-active': state.theme.button==='rounded'}"
@click="state.theme.button='rounded'">
<span class="mini"></span><span class="lbl">Rounded</span>
</button>
<button class="btn-style-opt" data-style="square"
:class="{'is-active': state.theme.button==='square'}"
@click="state.theme.button='square'">
<span class="mini"></span><span class="lbl">Square</span>
</button>
<button class="btn-style-opt" data-style="pill"
:class="{'is-active': state.theme.button==='pill'}"
@click="state.theme.button='pill'">
<span class="mini"></span><span class="lbl">Pill</span>
</button>
<button class="btn-style-opt" data-style="outline"
:class="{'is-active': state.theme.button==='outline'}"
@click="state.theme.button='outline'">
<span class="mini"></span><span class="lbl">Outline</span>
</button>
<button class="btn-style-opt" data-style="shadow"
:class="{'is-active': state.theme.button==='shadow'}"
@click="state.theme.button='shadow'">
<span class="mini"></span><span class="lbl">Shadow</span>
</button>
</div>
{{-- Border color (always visible) --}}
<div class="field-row" style="margin-top:10px">
<div class="field">
<span class="label">Border color</span>
<div class="color-row">
<label class="color-swatch" :style="'background:'+(state.theme.borderColor||'#7c3aed')">
<input type="color" :value="state.theme.borderColor||'#7c3aed'"
@input="syncColor('borderColor', $event.target.value)">
</label>
<input class="input" type="text" :value="state.theme.borderColor||'#7c3aed'"
@input="if(/^#[0-9a-f]{6}$/i.test($event.target.value)) syncColor('borderColor', $event.target.value)">
</div>
</div>
<div class="field" x-show="state.theme.button==='shadow'">
<span class="label">Shadow color</span>
<div class="color-row">
<label class="color-swatch" :style="'background:'+(state.theme.shadowColor||'#18181b')">
<input type="color" :value="state.theme.shadowColor||'#18181b'"
@input="syncColor('shadowColor', $event.target.value)">
</label>
<input class="input" type="text" :value="state.theme.shadowColor||'#18181b'"
@input="if(/^#[0-9a-f]{6}$/i.test($event.target.value)) syncColor('shadowColor', $event.target.value)">
</div>
</div>
</div>
</div>
{{-- Font --}}
<div class="field">
<span class="label">Font</span>
<div class="font-grid">
<button class="font-opt" data-font="geist"
:class="{'is-active': state.theme.font==='geist'}"
@click="state.theme.font='geist'">
<div class="fn-name">Sans · Geist</div>
<div class="fn-sample" style="font-family:'Geist'">Ag · Hello</div>
</button>
<button class="font-opt" data-font="grotesk"
:class="{'is-active': state.theme.font==='grotesk'}"
@click="state.theme.font='grotesk'">
<div class="fn-name">Grotesk · Space</div>
<div class="fn-sample" style="font-family:'Space Grotesk'">Ag · Hello</div>
</button>
<button class="font-opt" data-font="serif"
:class="{'is-active': state.theme.font==='serif'}"
@click="state.theme.font='serif'">
<div class="fn-name">Serif · Playfair</div>
<div class="fn-sample" style="font-family:'Playfair Display'">Ag · Hello</div>
</button>
<button class="font-opt" data-font="display"
:class="{'is-active': state.theme.font==='display'}"
@click="state.theme.font='display'">
<div class="fn-name">Display · DM</div>
<div class="fn-sample" style="font-family:'DM Serif Display'">Ag · Hello</div>
</button>
</div>
</div>
</div>
</div>
{{-- Section 3: Social Icons --}}
<div class="section">
<div class="section-head">
<span class="num">3</span>
<h3>Social icons</h3>
<span class="hint">Tap-row above your links</span>
</div>
<div class="section-body">
{{-- Current socials --}}
<div style="display:grid; gap:8px">
<template x-for="(social, idx) in state.socials" :key="social.id">
<div class="social-row">
<span class="social-icon-btn">
<svg><use :href="'#i-' + social.platform"/></svg>
</span>
<input class="input" type="text"
x-model="social.url"
placeholder="https://...">
<div class="switch" :class="{'is-on': social.visible}"
@click="social.visible = !social.visible"
style="cursor:pointer"></div>
<button class="icon-del" aria-label="Remove"
@click="removeSocial(social.id)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
</button>
</div>
</template>
</div>
{{-- Add-social chips --}}
<div class="add-social">
<template x-for="platform in availableSocials" :key="platform.id">
<button class="add-social-chip" @click="addSocial(platform.id)">
<svg><use :href="'#i-' + platform.id"/></svg>
<span x-text="platform.name"></span>
</button>
</template>
</div>
</div>
</div>
{{-- Section 4: Links & Blocks --}}
<div class="section">
<div class="section-head">
<span class="num">4</span>
<h3>Links &amp; Blocks</h3>
<span class="hint">Reorder with ↑↓, click row to expand</span>
</div>
<div class="section-body">
<div class="links-list">
<template x-for="(block, idx) in state.blocks" :key="block.id">
{{-- single root required by Alpine x-for; display:contents is invisible to the grid --}}
<div style="display:contents">
{{-- Header block --}}
<div x-show="block.type === 'header'" class="link-card"
:draggable="true"
@dragstart="onDragStart(block.id, $event)"
@dragover.prevent="onDragOver(block.id, $event)"
@drop.prevent="onDrop(block.id)"
@dragend="onDragEnd()"
:class="{'drag-over': dragOverId === block.id}">
<div class="link-head" style="grid-template-columns:auto 1fr auto auto auto">
<span class="grip">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="6" r="1.2" fill="currentColor"/><circle cx="9" cy="12" r="1.2" fill="currentColor"/><circle cx="9" cy="18" r="1.2" fill="currentColor"/><circle cx="15" cy="6" r="1.2" fill="currentColor"/><circle cx="15" cy="12" r="1.2" fill="currentColor"/><circle cx="15" cy="18" r="1.2" fill="currentColor"/></svg>
</span>
<input class="input" type="text" placeholder="Section header"
x-model="block.text"
style="background:transparent;border:0;padding:6px 8px;font-weight:600">
<button style="padding:8px;border-radius:6px;color:var(--color-t3);cursor:pointer" @click="moveBlock(block.id, 'up')" title="Move up">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>
</button>
<button style="padding:8px;border-radius:6px;color:var(--color-t3);cursor:pointer" @click="moveBlock(block.id, 'down')" title="Move down">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<button class="lc-del" style="padding:8px;border-radius:6px;color:var(--color-t3);cursor:pointer" @click="removeBlock(block.id)" title="Delete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"/></svg>
</button>
</div>
</div>
{{-- Divider block --}}
<div x-show="block.type === 'divider'" class="link-card"
:draggable="true"
@dragstart="onDragStart(block.id, $event)"
@dragover.prevent="onDragOver(block.id, $event)"
@drop.prevent="onDrop(block.id)"
@dragend="onDragEnd()"
:class="{'drag-over': dragOverId === block.id}">
<div class="link-head" style="grid-template-columns:auto 1fr auto auto auto">
<span class="grip">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="6" r="1.2" fill="currentColor"/><circle cx="9" cy="12" r="1.2" fill="currentColor"/><circle cx="9" cy="18" r="1.2" fill="currentColor"/><circle cx="15" cy="6" r="1.2" fill="currentColor"/><circle cx="15" cy="12" r="1.2" fill="currentColor"/><circle cx="15" cy="18" r="1.2" fill="currentColor"/></svg>
</span>
<span style="color: var(--color-t3); font-size: 13px;"> Divider </span>
<button style="padding:8px;border-radius:6px;color:var(--color-t3);cursor:pointer" @click="moveBlock(block.id, 'up')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>
</button>
<button style="padding:8px;border-radius:6px;color:var(--color-t3);cursor:pointer" @click="moveBlock(block.id, 'down')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<button class="lc-del" style="padding:8px;border-radius:6px;color:var(--color-t3);cursor:pointer" @click="removeBlock(block.id)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"/></svg>
</button>
</div>
</div>
{{-- Link block --}}
<div x-show="block.type === 'link'" class="link-card"
:class="{'is-collapsed': block._collapsed, 'drag-over': dragOverId === block.id}"
:draggable="true"
@dragstart="onDragStart(block.id, $event)"
@dragover.prevent="onDragOver(block.id, $event)"
@drop.prevent="onDrop(block.id)"
@dragend="onDragEnd()">
<div class="link-head">
<span class="grip">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="6" r="1.2" fill="currentColor"/><circle cx="9" cy="12" r="1.2" fill="currentColor"/><circle cx="9" cy="18" r="1.2" fill="currentColor"/><circle cx="15" cy="6" r="1.2" fill="currentColor"/><circle cx="15" cy="12" r="1.2" fill="currentColor"/><circle cx="15" cy="18" r="1.2" fill="currentColor"/></svg>
</span>
<div class="link-thumb">
<template x-if="block.icon">
<img :src="block.icon" alt="">
</template>
<template x-if="!block.icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
</template>
</div>
<div style="min-width:0; display:grid; cursor:pointer"
@click="block._collapsed = !block._collapsed">
<div class="link-title-prev" x-text="block.title || 'Untitled link'"></div>
<div class="link-url-prev" x-text="block.url || 'no url'"></div>
</div>
<div class="link-actions">
<button @click="moveBlock(block.id, 'up')" title="Move up">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>
</button>
<button @click="moveBlock(block.id, 'down')" title="Move down">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<button class="del" @click="removeBlock(block.id)" title="Delete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"/></svg>
</button>
</div>
<div class="switch" :class="{'is-on': block.enabled}"
@click.stop="block.enabled = !block.enabled"
style="cursor:pointer"></div>
</div>
<div class="link-body">
<div class="field">
<label class="label">Title</label>
<input class="input" type="text" x-model="block.title" placeholder="Link title">
</div>
<div class="field">
<label class="label">Subtitle</label>
<input class="input" type="text" x-model="block.subtitle" placeholder="Optional caption">
</div>
<div class="field">
<label class="label">URL</label>
<input class="input" type="text" x-model="block.url" placeholder="https://...">
</div>
<div class="field-row">
<div class="field">
<label class="label">Style</label>
<select class="select" x-model="block.style">
<option value="default">Default</option>
<option value="feature">Featured (gradient)</option>
</select>
</div>
<div class="toggle-row" style="padding:8px 12px; cursor:pointer"
@click="block.pinned = !block.pinned">
<div class="t-text">
<div class="t-title" style="font-size:12.5px">Pinned</div>
</div>
<div class="switch" :class="{'is-on': block.pinned}"></div>
</div>
</div>
</div>
</div>
</div>{{-- /display:contents wrapper --}}
</template>
</div>
{{-- Add block buttons --}}
<div class="add-block-row">
<button class="btn" @click="addBlock('link')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add link
</button>
<button class="btn" @click="addBlock('header')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="14" y2="12"/><line x1="4" y1="18" x2="18" y2="18"/></svg>
Add header
</button>
<button class="btn" @click="addBlock('divider')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/></svg>
Add divider
</button>
</div>
</div>
</div>
{{-- Section 5: Page settings --}}
<div class="section">
<div class="section-head">
<span class="num">5</span>
<h3>Page settings</h3>
<span class="hint">URL &amp; visibility</span>
</div>
<div class="section-body">
<div class="field">
<label class="label">Page URL</label>
<div class="input-prefix">
<span class="prefix">{{ $displayUrlBase }}/</span>
<input class="input" type="text"
x-model="state.slug"
:class="slugTaken ? 'border-red/60 focus:ring-red/40 focus:border-red/60' : ''"
@input="
state.slug = $event.target.value.replace(/[^a-z0-9\-_]/gi, '').toLowerCase();
$event.target.value = state.slug;
clearTimeout(_slugTimer);
if (state.slug) {
_slugTimer = setTimeout(async () => {
slugTaken = await $wire.checkSlug(state.slug);
}, 350);
} else {
slugTaken = false;
}
"
placeholder="yourslug">
</div>
<div x-show="slugTaken" x-cloak class="mt-1 text-xs text-red">Dieser Slug ist bereits vergeben.</div>
<div class="help" x-show="!slugTaken">Lowercase, no spaces. Connect a custom domain in Domains.</div>
</div>
<div class="toggle-row" style="cursor:pointer"
@click="state.isPublished = !state.isPublished">
<div class="t-text">
<div class="t-title">Published</div>
<div class="t-desc">When enabled, your bio page is publicly visible.</div>
</div>
<div class="switch" :class="{'is-on': state.isPublished}"></div>
</div>
</div>
</div>
</section>
{{-- ============ Preview (right) ============ --}}
<aside class="preview-pane">
<div class="device-toolbar">
<div class="device-tabs">
<div class="device-tab" :class="device==='mobile' && 'is-active'" @click="device='mobile'">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12" y2="18"/></svg>
Mobile
</div>
<div class="device-tab" :class="device==='desktop' && 'is-active'" @click="device='desktop'">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
Desktop
</div>
</div>
<div class="preview-meta-mini" x-text="displayUrl"></div>
</div>
<div class="device" :class="device" aria-label="Live preview">
<div class="device-screen">
<div class="bio-scroll">
<div class="bio"
:class="{'is-dark': state.theme.dark}"
:data-btn="state.theme.button"
:data-font="state.theme.font"
:style="`--bio-btn-border:${state.theme.borderColor||'#7c3aed'};--bio-btn-shadow:${state.theme.shadowColor||'#18181b'}`">
<div class="bg-overlay" :style="previewBgStyle"></div>
{{-- bioAvatarHtml builds <img src="data:..."> or <div class="av-fallback">X</div> from state.profile --}}
<div class="bio-avatar" x-html="bioAvatarHtml"></div>
{{-- bioNameHtml appends verified badge SVG; source is state.profile.displayName --}}
<h1 class="bio-name" x-html="bioNameHtml"></h1>
<p class="bio-handle" x-text="'@' + state.profile.handle"></p>
<p class="bio-text" x-text="state.profile.bio"></p>
{{-- renderSocialsHtml / renderBlocksHtml build preview <a> tags from state arrays --}}
<div class="bio-socials" x-html="renderSocialsHtml()"></div>
<div class="bio-links" x-html="renderBlocksHtml()"></div>
<div class="bio-footer">
<span class="made">Made with <strong>nimuli</strong></span>
</div>
</div>
</div>
</div>
</div>
<div class="preview-actions">
<a :href="publicUrl" target="_blank" class="btn" style="flex:1; justify-content:center">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="3" y1="12" x2="21" y2="12"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18"/></svg>
View public
</a>
<button class="btn copy-link-btn" style="flex:1; justify-content:center"
x-data="{copied:false}"
@click="navigator.clipboard?.writeText(publicUrl); copied=true; setTimeout(()=>copied=false,1200)">
<template x-if="!copied">
<span style="display:contents">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy link
</span>
</template>
<template x-if="copied">
<span>Copied!</span>
</template>
</button>
</div>
</aside>
</div>
{{-- =================== Crop Modal =================== --}}
<div x-show="cropState.open" class="crop-modal" style="display:none"
@mousemove="cropDragMove($event)" @mouseup="cropDragEnd()">
<div class="crop-dialog" @click.stop>
<div class="crop-title">Crop image</div>
<p class="crop-hint">Drag to reposition · scroll or slider to zoom</p>
<div class="crop-stage" x-ref="cropStage"
@mousedown.prevent="cropDragStart($event)"
@touchstart.prevent="cropDragStart($event)"
@touchmove.prevent="cropDragMove($event)"
@touchend="cropDragEnd()"
@wheel.prevent="cropZoom($event)">
<img x-ref="cropImg"
:src="cropState.src || ''"
x-show="cropState.src"
@load="cropImgLoaded()"
:style="`transform: translate(calc(-50% + ${cropState.x}px), calc(-50% + ${cropState.y}px)) scale(${cropState.scale})`"
draggable="false">
<div class="crop-frame"></div>
</div>
<div class="crop-zoom-row">
<span class="crop-zoom-icon"></span>
<input type="range" :min="cropState.minScale" max="4" step="0.02" x-model.number="cropState.scale">
<span class="crop-zoom-icon">+</span>
</div>
<div class="crop-btns">
<button class="btn ghost" @click="cropState.open = false">Cancel</button>
<button class="btn primary" @click="applyCrop()">Apply crop</button>
</div>
</div>
</div>
{{-- =================== SVG Social Icon Sprite =================== --}}
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="i-instagram" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="5" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="17.5" cy="6.5" r="1.2" fill="currentColor"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M4 4l16 16M20 4L4 20" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></symbol>
<symbol id="i-tiktok" viewBox="0 0 24 24"><path d="M14 4v9a4 4 0 1 1-4-4M14 4a5 5 0 0 0 5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="i-youtube" viewBox="0 0 24 24"><rect x="2.5" y="6" width="19" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><polygon points="10,9 16,12 10,15" fill="currentColor"/></symbol>
<symbol id="i-linkedin" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><line x1="8" y1="10" x2="8" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="8" cy="7.5" r="1" fill="currentColor"/><path d="M12 16v-3a2 2 0 0 1 4 0v3M12 10v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></symbol>
<symbol id="i-github" viewBox="0 0 24 24"><path d="M9 19c-4 1-4-2-6-2m12 4v-3.5c0-1 0-1.5-.5-2 3-.5 5-2 5-5.5 0-1.5-.5-2.5-1-3.5.5-1.5 0-3-.5-3.5C16 3 14.5 4 13 5h-2C9.5 4 8 3 6.5 3.5 6 4 5.5 5.5 6 7c-.5 1-1 2-1 3.5 0 3.5 2 5 5 5.5-.5.5-.5 1-.5 2V21" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="i-facebook" viewBox="0 0 24 24"><path d="M13 22V12h3l1-4h-4V6c0-1 0-2 2-2h2V0h-3c-3 0-4 2-4 4v4H7v4h3v10z" fill="currentColor"/></symbol>
<symbol id="i-spotify" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><path d="M7 9.5c3-1 8-1 11 1M7.5 13c2.5-1 6-.7 8.5.8M8 16c2-.7 4.5-.6 6.5.4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol>
<symbol id="i-discord" viewBox="0 0 24 24"><path d="M7 8c2-1 8-1 10 0l1 1c1 2 2 5 1.5 8l-3 2-1-2c-3 1-5 1-7 0l-1 2-3-2c-.5-3 .5-6 1.5-8z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><circle cx="9.5" cy="13" r="1" fill="currentColor"/><circle cx="14.5" cy="13" r="1" fill="currentColor"/></symbol>
<symbol id="i-pinterest" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><path d="M11 7c-2 0-4 1.5-4 4 0 1.5 1 2.5 1.5 2.5s.5-1 .5-1.5c-.5-1 0-3.5 2-3.5s2.5 2 2 4-2 3-3 2c-.5 2-1.5 4-1.5 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol>
<symbol id="i-twitch" viewBox="0 0 24 24"><path d="M4 4h16v10l-4 4h-4l-2 2H8v-2H4V4z M9 8v5 M14 8v5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/></symbol>
<symbol id="i-snapchat" viewBox="0 0 24 24"><path d="M5 16c2 0 3-1.5 3-3.5V10c0-3 2-5 4-5s4 2 4 5v2.5c0 2 1 3.5 3 3.5-.5 1-2 1.5-3 1.5-.5 1-1.5 2-4 2s-3.5-1-4-2c-1 0-2.5-.5-3-1.5z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></symbol>
<symbol id="i-website" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><line x1="3" y1="12" x2="21" y2="12" stroke="currentColor" stroke-width="2"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18" fill="none" stroke="currentColor" stroke-width="2"/></symbol>
<symbol id="i-email" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><polyline points="3 7 12 13 21 7" fill="none" stroke="currentColor" stroke-width="2"/></symbol>
<symbol id="i-whatsapp" viewBox="0 0 24 24"><path d="M3 21l1.5-4.5A8 8 0 1 1 8 20z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M9 10c0 4 3 6 6 6l1.5-1.5-2.5-1.5c-.5.5-1 1-1.5.5s-2-2-2.5-3 0-1 .5-1.5L9 10z" fill="currentColor"/></symbol>
</defs>
</svg>
</div>
{{-- script outside root div: libxml2 parseHTML() interprets </div> in JS strings as closing the outer element --}}
<script>
function bioEditor(init) {
let uid = (init.uid || 100) + 1;
return {
device: 'mobile',
dragSrcId: null,
dragOverId: null,
slugTaken: false,
_slugTimer: null,
cropState: { open: false, src: null, type: null, x: 0, y: 0, scale: 1, minScale: 1, _drag: null },
state: {
profile: init.profile || {avatar: null, displayName: '', handle: '', bio: '', verified: false},
theme: init.theme || {bgType: 'gradient', bg1: '#0f0a2e', bg2: '#6b1b9a', bgImage: null, dark: true, button: 'rounded', font: 'geist', borderColor: '#7c3aed', shadowColor: '#18181b'},
socials: init.socials || [],
blocks: (init.blocks || []).map(b => ({...b, _collapsed: true})),
slug: init.slug || '',
isPublished: init.isPublished || false,
},
THEMES: {
midnight: {bgType: 'gradient', bg1: '#0f0a2e', bg2: '#6b1b9a', dark: true},
sunset: {bgType: 'gradient', bg1: '#ff8a3d', bg2: '#b91c5c', dark: true},
forest: {bgType: 'gradient', bg1: '#064e3b', bg2: '#0f766e', dark: true},
paper: {bgType: 'solid', bg1: '#faf8f3', bg2: '#faf8f3', dark: false},
ocean: {bgType: 'gradient', bg1: '#0c4a6e', bg2: '#0ea5e9', dark: true},
candy: {bgType: 'gradient', bg1: '#fce7f3', bg2: '#f9a8d4', dark: false},
},
SOCIAL_LIB: [
{id: 'instagram', name: 'Instagram'},
{id: 'tiktok', name: 'TikTok'},
{id: 'x', name: 'X'},
{id: 'youtube', name: 'YouTube'},
{id: 'linkedin', name: 'LinkedIn'},
{id: 'github', name: 'GitHub'},
{id: 'facebook', name: 'Facebook'},
{id: 'spotify', name: 'Spotify'},
{id: 'discord', name: 'Discord'},
{id: 'pinterest', name: 'Pinterest'},
{id: 'twitch', name: 'Twitch'},
{id: 'snapchat', name: 'Snapchat'},
{id: 'whatsapp', name: 'WhatsApp'},
{id: 'website', name: 'Website'},
{id: 'email', name: 'Email'},
],
get availableSocials() {
const existing = new Set(this.state.socials.map(s => s.platform));
return this.SOCIAL_LIB.filter(p => !existing.has(p.id));
},
get previewBgStyle() {
const t = this.state.theme;
if (t.bgType === 'solid') return 'background: ' + t.bg1;
if (t.bgType === 'image' && t.bgImage) return 'background: url(' + t.bgImage + ') center/cover no-repeat';
return 'background: linear-gradient(160deg, ' + t.bg1 + ', ' + t.bg2 + ')';
},
get publicUrl() {
return {{ Js::from($publicUrlBase) }} + '/' + this.state.slug;
},
get displayUrl() {
return {{ Js::from($displayUrlBase) }} + '/' + (this.state.slug || '…');
},
ensureHttp(url) {
if (!url) return '#';
if (/^(https?:|mailto:|tel:)/i.test(url)) return url;
return 'https://' + url;
},
// Preview HTML builders — all values sourced from Alpine state (not raw server HTML)
renderBlocksHtml() {
return this.state.blocks
.filter(b => b.type !== 'link' || b.enabled !== false)
.map(b => {
if (b.type === 'header') {
const txt = document.createElement('span');
txt.textContent = b.text || '';
return '<div class="bio-header">' + txt.innerHTML + '</div>';
}
if (b.type === 'divider') return '<div class="bio-divider"></div>';
const cls = 'bio-link' + (b.style === 'feature' ? ' is-feature' : '') + (b.pinned ? ' is-pinned' : '');
const icon = b.icon
? '<span class="bl-icon"><img src="' + b.icon + '" alt=""></span>'
: '<span class="bl-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07L11.59 4.34"/><path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.41-1.41"/></svg></span>';
const titleEl = document.createElement('span'); titleEl.textContent = b.title || 'Untitled';
const subEl = document.createElement('span'); subEl.textContent = b.subtitle || '';
return '<a class="' + cls + '" href="' + this.ensureHttp(b.url) + '" target="_blank" rel="noopener">'
+ (b.pinned ? '<span class="pin-badge">Pinned</span>' : '')
+ icon
+ '<div class="bl-text"><div class="bl-title">' + titleEl.innerHTML + '</div>'
+ (b.subtitle ? '<div class="bl-sub">' + subEl.innerHTML + '</div>' : '')
+ '</div>'
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:.5"><polyline points="9 18 15 12 9 6"/></svg>'
+ '</a>';
}).join('');
},
renderSocialsHtml() {
return this.state.socials.filter(s => s.visible && s.url).map(s =>
'<a class="bio-social" href="' + this.ensureHttp(s.url) + '" target="_blank" rel="noopener" aria-label="' + s.platform + '">'
+ '<svg><use href="#i-' + s.platform + '"/></svg>'
+ '</a>'
).join('');
},
get bioNameHtml() {
const el = document.createElement('span');
el.textContent = this.state.profile.displayName || '';
const safeName = el.innerHTML;
return safeName + (this.state.profile.verified
? '<span class="verified-badge"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>'
: '');
},
get bioAvatarHtml() {
if (this.state.profile.avatar) {
return '<img src="' + this.state.profile.avatar + '" alt="">';
}
return '<div class="av-fallback"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg></div>';
},
applyThemePreset(key) {
const t = this.THEMES[key];
if (!t) return;
Object.assign(this.state.theme, t);
},
addSocial(platform) {
this.state.socials.push({id: uid++, platform, url: '', visible: true});
},
removeSocial(id) {
this.state.socials = this.state.socials.filter(s => s.id !== id);
},
addBlock(type) {
if (type === 'link') {
this.state.blocks.push({id: uid++, type: 'link', title: '', subtitle: '', url: '', icon: null, style: 'default', pinned: false, enabled: true, _collapsed: false});
} else if (type === 'header') {
this.state.blocks.push({id: uid++, type: 'header', text: 'New section'});
} else if (type === 'divider') {
this.state.blocks.push({id: uid++, type: 'divider'});
}
},
removeBlock(id) {
this.state.blocks = this.state.blocks.filter(b => b.id !== id);
},
moveBlock(id, dir) {
const i = this.state.blocks.findIndex(b => b.id === id);
if (dir === 'up' && i > 0) {
[this.state.blocks[i], this.state.blocks[i - 1]] = [this.state.blocks[i - 1], this.state.blocks[i]];
}
if (dir === 'down' && i < this.state.blocks.length - 1) {
[this.state.blocks[i], this.state.blocks[i + 1]] = [this.state.blocks[i + 1], this.state.blocks[i]];
}
},
onDragStart(id, e) {
this.dragSrcId = id;
e.dataTransfer.effectAllowed = 'move';
},
onDragOver(id, e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
this.dragOverId = id;
},
onDrop(id) {
if (this.dragSrcId === null || this.dragSrcId === id) {
this.dragSrcId = null; this.dragOverId = null; return;
}
const from = this.state.blocks.findIndex(b => b.id === this.dragSrcId);
const to = this.state.blocks.findIndex(b => b.id === id);
if (from === -1 || to === -1) { this.dragSrcId = null; this.dragOverId = null; return; }
const arr = [...this.state.blocks];
const [moved] = arr.splice(from, 1);
arr.splice(to, 0, moved);
this.state.blocks = arr;
this.dragSrcId = null; this.dragOverId = null;
},
onDragEnd() {
this.dragSrcId = null; this.dragOverId = null;
},
// Read JPEG EXIF orientation tag from first 64 KB (no library needed)
getOrientation(file) {
return new Promise((resolve) => {
const r = new FileReader();
r.onload = (e) => {
let o = 1;
try {
const v = new DataView(e.target.result);
if (v.getUint16(0, false) === 0xFFD8) {
let off = 2;
while (off < v.byteLength) {
const m = v.getUint16(off, false); off += 2;
if (m === 0xFFE1) {
if (v.getUint32((off += 2), false) === 0x45786966) {
const le = v.getUint16((off += 6), false) === 0x4949;
off += v.getUint32(off + 4, le);
const n = v.getUint16(off, le); off += 2;
for (let i = 0; i < n; i++) {
if (v.getUint16(off + i * 12, le) === 0x0112) {
o = v.getUint16(off + i * 12 + 8, le); break;
}
}
}
break;
} else if ((m & 0xFF00) !== 0xFF00) break;
else off += v.getUint16(off, false);
}
}
} catch (_) {}
resolve(o);
};
r.readAsArrayBuffer(file.slice(0, 65536));
});
},
// Draw image to a correctly-oriented canvas
applyOrientation(img, o) {
const w = img.naturalWidth, h = img.naturalHeight;
const swap = o >= 5;
const c = document.createElement('canvas');
c.width = swap ? h : w; c.height = swap ? w : h;
const ctx = c.getContext('2d');
if (o === 2) ctx.transform(-1,0,0,1,w,0);
else if (o === 3) ctx.transform(-1,0,0,-1,w,h);
else if (o === 4) ctx.transform(1,0,0,-1,0,h);
else if (o === 5) ctx.transform(0,1,1,0,0,0);
else if (o === 6) ctx.transform(0,1,-1,0,h,0);
else if (o === 7) ctx.transform(0,-1,-1,0,h,w);
else if (o === 8) ctx.transform(0,-1,1,0,0,w);
ctx.drawImage(img, 0, 0);
return c;
},
// Resize + EXIF-fix (for background images — no crop needed)
async compressImage(file, maxDim, quality) {
const orientation = await this.getOrientation(file);
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const oriented = this.applyOrientation(img, orientation);
let w = oriented.width, h = oriented.height;
if (w > maxDim || h > maxDim) {
const r = maxDim / Math.max(w, h);
w = Math.round(w * r); h = Math.round(h * r);
}
const c = document.createElement('canvas');
c.width = w; c.height = h;
c.getContext('2d').drawImage(oriented, 0, 0, w, h);
URL.revokeObjectURL(img.src);
resolve(c.toDataURL('image/jpeg', quality));
};
img.src = URL.createObjectURL(file);
});
},
// Avatar: fix EXIF then open crop modal
async uploadAvatar(file) {
const orientation = await this.getOrientation(file);
const img = new Image();
await new Promise(res => { img.onload = res; img.src = URL.createObjectURL(file); });
const oriented = this.applyOrientation(img, orientation);
URL.revokeObjectURL(img.src);
this.cropState = {
open: true, src: oriented.toDataURL('image/jpeg', 0.95),
type: 'avatar', x: 0, y: 0, scale: 1, minScale: 1, _drag: null,
};
},
// Bg image: EXIF fix + resize only
async uploadBgImage(file) {
this.state.theme.bgImage = await this.compressImage(file, 1080, 0.80);
},
// Crop modal — image loaded: fit whole image in view, allow zoom in/out
cropImgLoaded() {
const img = this.$refs.cropImg;
if (!img || !img.naturalWidth) return;
const { naturalWidth: nw, naturalHeight: nh } = img;
const fitScale = +Math.min(240 / nw, 240 / nh).toFixed(3);
const minScale = +Math.max(0.05, fitScale * 0.3).toFixed(3);
this.cropState.minScale = minScale;
this.cropState.scale = fitScale;
this.cropState.x = 0; this.cropState.y = 0;
},
cropDragStart(e) {
const p = e.touches ? e.touches[0] : e;
this.cropState._drag = { sx: p.clientX, sy: p.clientY, ox: this.cropState.x, oy: this.cropState.y };
},
cropDragMove(e) {
if (!this.cropState._drag) return;
e.preventDefault();
const p = e.touches ? e.touches[0] : e;
this.cropState.x = this.cropState._drag.ox + (p.clientX - this.cropState._drag.sx);
this.cropState.y = this.cropState._drag.oy + (p.clientY - this.cropState._drag.sy);
},
cropDragEnd() { this.cropState._drag = null; },
cropZoom(e) {
const delta = e.deltaY < 0 ? 0.1 : -0.1;
this.cropState.scale = Math.max(
this.cropState.minScale,
Math.min(4, +(this.cropState.scale + delta).toFixed(3))
);
},
// Extract the 240×240 crop area and output as 400×400 JPEG
applyCrop() {
const img = this.$refs.cropImg;
if (!img) return;
const OUT = 400, CROP = 240, STAGE = 300;
const { naturalWidth: nw, naturalHeight: nh } = img;
const { scale, x, y } = this.cropState;
// Image top-left position in stage coords (image is centered at stage center + offset)
const imgX = STAGE / 2 + x - (nw * scale) / 2;
const imgY = STAGE / 2 + y - (nh * scale) / 2;
// Crop frame top-left in stage: (30, 30) — centered in 300px stage with 30px padding
const srcX = (30 - imgX) / scale;
const srcY = (30 - imgY) / scale;
const srcW = CROP / scale;
const srcH = CROP / scale;
const c = document.createElement('canvas');
c.width = OUT; c.height = OUT;
c.getContext('2d').drawImage(img, srcX, srcY, srcW, srcH, 0, 0, OUT, OUT);
this.state.profile.avatar = c.toDataURL('image/jpeg', 0.85);
this.cropState.open = false;
},
syncColor(key, val) {
this.state.theme[key] = val;
},
async save() {
const snap = JSON.parse(JSON.stringify(this.state));
await this.$wire.save(snap);
this.state.profile = snap.profile;
this.state.theme = snap.theme;
this.state.socials = snap.socials;
this.state.blocks = snap.blocks;
this.state.slug = snap.slug;
this.state.isPublished = false;
},
async publish() {
const snap = JSON.parse(JSON.stringify(this.state));
await this.$wire.publish(snap);
this.state.profile = snap.profile;
this.state.theme = snap.theme;
this.state.socials = snap.socials;
this.state.blocks = snap.blocks;
this.state.slug = snap.slug;
this.state.isPublished = true;
},
};
}
</script>
@script
<script>
(function () {
var mainEl = document.querySelector('main.flex-1');
var contentEl = mainEl && mainEl.parentElement;
var rootEl = mainEl && mainEl.querySelector('.bio-editor-root');
function applyLayout() {
if (window.innerWidth > 1180 && mainEl && contentEl && rootEl) {
contentEl.style.height = '100dvh';
mainEl.style.overflow = 'hidden';
mainEl.style.padding = '0';
mainEl.style.minHeight = '0';
rootEl.style.height = '100%';
rootEl.style.overflow = 'hidden';
}
}
applyLayout();
Livewire.hook('commit', function (ref) {
ref.succeed(function () {
requestAnimationFrame(applyLayout);
});
});
document.addEventListener('livewire:navigating', function restore() {
if (contentEl) contentEl.style.height = '';
if (mainEl) { mainEl.style.overflow = ''; mainEl.style.padding = ''; mainEl.style.minHeight = ''; }
if (rootEl) { rootEl.style.height = ''; rootEl.style.overflow = ''; }
document.removeEventListener('livewire:navigating', restore);
});
})();
</script>
@endscript