1091 lines
68 KiB
PHP
1091 lines
68 KiB
PHP
{{--
|
||
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 & 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 & 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
|
||
|