feat(ui): blade-heroicons, local fonts, smaller base font, no emoji

- Install blade-ui-kit/blade-heroicons, replace emoji with x-heroicon-* components
- Inter Variable + JetBrains Mono served from /public/fonts/, no CDN
- html { font-size: 14px }, h1/h2/h3 scaled down
- Remove Google Fonts CDN links from all layouts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-16 15:13:53 +02:00
parent 2510620f67
commit 278bc42b37
63 changed files with 467 additions and 67 deletions

View File

@ -7,6 +7,8 @@
"license": "MIT",
"require": {
"php": "^8.2",
"blade-ui-kit/blade-heroicons": "^2.7",
"blade-ui-kit/blade-icons": "^1.10",
"chillerlan/php-qrcode": "^6.0",
"geoip2/geoip2": "^3.3",
"intervention/image-laravel": "^4.0",
@ -44,6 +46,7 @@
"pestphp/pest-plugin-livewire": "^3.0"
},
"autoload": {
"files": ["app/helpers.php"],
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",

152
composer.lock generated
View File

@ -4,8 +4,158 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "97ac63318d01d0c4ccc4382c8cee808a",
"content-hash": "896e38d3b247536fbdea6128d719444a",
"packages": [
{
"name": "blade-ui-kit/blade-heroicons",
"version": "2.7.0",
"source": {
"type": "git",
"url": "https://github.com/driesvints/blade-heroicons.git",
"reference": "66fa8ba09dba12e0cdb410b8cb94f3b890eca440"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/driesvints/blade-heroicons/zipball/66fa8ba09dba12e0cdb410b8cb94f3b890eca440",
"reference": "66fa8ba09dba12e0cdb410b8cb94f3b890eca440",
"shasum": ""
},
"require": {
"blade-ui-kit/blade-icons": "^1.6",
"illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0"
},
"require-dev": {
"orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0",
"phpunit/phpunit": "^9.0|^10.5|^11.0|^12.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"BladeUI\\Heroicons\\BladeHeroiconsServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"BladeUI\\Heroicons\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Dries Vints",
"homepage": "https://driesvints.com"
}
],
"description": "A package to easily make use of Heroicons in your Laravel Blade views.",
"homepage": "https://github.com/driesvints/blade-heroicons",
"keywords": [
"Heroicons",
"blade",
"laravel"
],
"support": {
"issues": "https://github.com/driesvints/blade-heroicons/issues",
"source": "https://github.com/driesvints/blade-heroicons/tree/2.7.0"
},
"funding": [
{
"url": "https://github.com/sponsors/driesvints",
"type": "github"
},
{
"url": "https://www.paypal.com/paypalme/driesvints",
"type": "paypal"
}
],
"time": "2026-03-16T13:00:23+00:00"
},
{
"name": "blade-ui-kit/blade-icons",
"version": "1.10.0",
"source": {
"type": "git",
"url": "https://github.com/driesvints/blade-icons.git",
"reference": "74189a80bbaa4966aebaee54fec3a3c2ef0a5f3a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/driesvints/blade-icons/zipball/74189a80bbaa4966aebaee54fec3a3c2ef0a5f3a",
"reference": "74189a80bbaa4966aebaee54fec3a3c2ef0a5f3a",
"shasum": ""
},
"require": {
"illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
"illuminate/filesystem": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
"illuminate/view": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
"php": "^7.4|^8.0",
"symfony/console": "^5.3|^6.0|^7.0|^8.0",
"symfony/finder": "^5.3|^6.0|^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "^1.5.1",
"orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
"phpunit/phpunit": "^9.0|^10.5|^11.0"
},
"bin": [
"bin/blade-icons-generate"
],
"type": "library",
"extra": {
"laravel": {
"providers": [
"BladeUI\\Icons\\BladeIconsServiceProvider"
]
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"BladeUI\\Icons\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Dries Vints",
"homepage": "https://driesvints.com"
}
],
"description": "A package to easily make use of icons in your Laravel Blade views.",
"homepage": "https://github.com/driesvints/blade-icons",
"keywords": [
"blade",
"icons",
"laravel",
"svg"
],
"support": {
"issues": "https://github.com/driesvints/blade-icons/issues",
"source": "https://github.com/driesvints/blade-icons"
},
"funding": [
{
"url": "https://github.com/sponsors/driesvints",
"type": "github"
},
{
"url": "https://www.paypal.com/paypalme/driesvints",
"type": "paypal"
}
],
"time": "2026-04-23T19:03:45+00:00"
},
{
"name": "brick/math",
"version": "0.14.8",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,3 +1,35 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter/InterVariable.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter/InterVariable-Italic.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600 700;
font-display: swap;
src: url('/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2') format('woff2');
}
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@ -53,6 +85,14 @@
/* Visible borders on all themes */
*, ::before, ::after { border-color: var(--color-b1); }
/* Base typography scale */
html { font-size: 14px; }
body { font-size: 0.875rem; line-height: 1.5; }
h1 { font-size: 1.5rem; line-height: 2rem; font-weight: 600; }
h2 { font-size: 1.25rem; line-height: 1.75rem; font-weight: 600; }
h3 { font-size: 1.125rem; line-height: 1.5rem; font-weight: 500; }
@layer utilities {
.bg-accent-gradient {
background: linear-gradient(135deg, var(--color-accent), var(--color-accent-2));

View File

@ -7,11 +7,6 @@
<title>{{ $title ?? config('app.name', 'Nimuli') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
@vite(['resources/js/app.js'])
@livewireStyles
</head>

View File

@ -51,7 +51,7 @@
min-height: 100vh;
}
body.app-ready { opacity: 1; }
/* Skeleton placeholders visible during JS load */
/* Skeleton sidebar & topbar */
.skel-sidebar {
position: fixed; top: 0; left: 0; bottom: 0;
width: 240px; background: var(--c-s1);
@ -66,7 +66,6 @@
.skel-sidebar { display: none; }
.skel-topbar { left: 0; }
}
/* Spinner: visible before app-ready, hidden after */
.app-spinner {
position: fixed; top: 50%; left: 50%;
transform: translate(-50%, -50%);
@ -83,24 +82,19 @@
@keyframes nim-spin { to { transform: translate(-50%, -50%) rotate(360deg); } }
</style>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
@vite(['resources/js/app.js'])
@livewireStyles
</head>
<body>
<!-- Skeleton: visible during JS load to prevent layout flash -->
<!-- Skeleton: visible during JS load -->
<div class="skel-sidebar" aria-hidden="true"></div>
<div class="skel-topbar" aria-hidden="true"></div>
<div class="app-spinner" aria-hidden="true"></div>
<div class="bg-bg text-t1 font-sans antialiased flex h-screen overflow-hidden">
<div class="bg-bg text-t1 font-sans antialiased min-h-screen">
<!-- Mobile Sidebar Overlay -->
<div
class="fixed inset-0 z-20 bg-black/60 md:hidden"
class="fixed inset-0 z-30 bg-black/60 md:hidden"
x-show="$store.sidebar?.open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
@ -115,17 +109,17 @@
<!-- Sidebar: persisted across wire:navigate to prevent re-render -->
@persist('sidebar')
<div
class="fixed inset-y-0 left-0 z-30 md:relative md:z-auto md:flex md:flex-shrink-0 transition-transform duration-200 ease-in-out"
:class="$store.sidebar?.open ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
>
<x-sidebar />
</div>
@endpersist
<!-- Main Area -->
<div class="flex-1 flex flex-col overflow-hidden min-w-0">
<x-topbar :title="$title ?? ''" />
<!-- Main content area: shifts right based on sidebar width -->
<div
class="flex flex-col min-h-screen transition-[margin-left] duration-200 ease-in-out md:ml-60"
:class="$store.sidebar?.collapsed ? 'md:ml-16' : 'md:ml-60'"
>
@persist('topbar')
<x-topbar :title="$title ?? ''" />
@endpersist
<main class="flex-1 overflow-y-auto p-6">
{{ $slot }}
@ -136,7 +130,7 @@
@livewire('wire-elements-modal')
@livewire('components.toaster')
@livewireScripts
<script>
(function () {
function markReady() { document.body.classList.add('app-ready'); }
@ -146,8 +140,40 @@
window.addEventListener('load', markReady);
setTimeout(markReady, 600);
}
// Re-apply on wire:navigate (SPA navigation)
document.addEventListener('livewire:navigated', markReady);
// Highlight active nav link after SPA navigation
function updateActiveNav() {
var path = window.location.pathname;
document.querySelectorAll('[data-nav-link]').forEach(function (el) {
var href = el.getAttribute('href');
var isActive = href && (path === href || path.startsWith(href + '/'));
el.classList.toggle('bg-blue/10', isActive);
el.classList.toggle('text-blue', isActive);
el.classList.toggle('text-t2', !isActive);
el.classList.toggle('hover:text-t1', !isActive);
el.classList.toggle('hover:bg-s2', !isActive);
});
}
document.addEventListener('DOMContentLoaded', updateActiveNav);
document.addEventListener('livewire:navigated', updateActiveNav);
// copy-to-clipboard Livewire event
document.addEventListener('livewire:init', function () {
Livewire.on('copy-to-clipboard', function (event) {
var text = event.text ?? (Array.isArray(event) ? event[0]?.text : null);
if (text) {
navigator.clipboard.writeText(text).catch(function () {
var ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
});
}
});
});
})();
</script>
</body>

View File

@ -32,7 +32,7 @@
<span class="flex-1 text-sm font-medium">{{ $t['message'] }}</span>
<button wire:click="dismiss('{{ $t['id'] }}')"
class="flex-shrink-0 opacity-50 hover:opacity-100 transition-opacity text-lg leading-none"
aria-label="Dismiss">×</button>
aria-label="Dismiss"><x-heroicon-o-x-mark class="w-3.5 h-3.5" /></button>
</div>
</div>
@endforeach

View File

@ -4,20 +4,50 @@
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Target URL *</label>
<input wire:model="targetUrl" type="url" placeholder="https://example.com/long-url"
<input wire:model.live="targetUrl" type="url" placeholder="https://example.com/long-url"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
@error('targetUrl') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Custom Slug (optional)</label>
<div class="flex items-center justify-between mb-1.5">
<label class="block text-xs font-medium text-t2">Custom Slug (optional)</label>
<button wire:click="suggestSlugs" wire:loading.attr="disabled" wire:target="suggestSlugs"
@disabled(!$targetUrl)
type="button"
class="flex items-center gap-1 text-xs text-purple hover:opacity-80 transition-opacity disabled:opacity-40">
<span wire:loading.remove wire:target="suggestSlugs">
<x-heroicon-o-sparkles class="w-3 h-3 inline" />
AI Slug
</span>
<span wire:loading wire:target="suggestSlugs" class="flex items-center gap-1">
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Thinking…
</span>
</button>
</div>
<div class="flex items-center bg-s2 border border-white/[.06] rounded-lg overflow-hidden focus-within:ring-1 focus-within:ring-blue/50">
<span class="px-3 py-2.5 text-sm text-t3 border-r border-white/[.06] flex-shrink-0">nimu.li/</span>
<input wire:model="slug" type="text" placeholder="auto-generated"
class="flex-1 px-3 py-2.5 bg-transparent text-sm text-t1 placeholder-t3 focus:outline-none font-mono">
<button wire:click="generateSlug" type="button"
class="px-3 py-2.5 text-xs text-t2 hover:text-t1 border-l border-white/[.06] flex-shrink-0 transition-colors">Generate</button>
<input wire:model.live="slug" type="text" placeholder="auto-generated"
class="flex-1 px-3 py-2.5 bg-transparent text-sm text-t1 placeholder-t3 focus:outline-none font-mono min-w-0">
<button wire:click="regenerateSlug" type="button" title="Regenerate slug"
class="px-3 py-2.5 text-t2 hover:text-t1 border-l border-white/[.06] flex-shrink-0 transition-colors">
<x-heroicon-o-arrow-path class="w-4 h-4" />
</button>
</div>
@if($slugSuggestions)
<div class="flex flex-wrap gap-1.5 mt-2">
@foreach($slugSuggestions as $suggestion)
<button wire:click="pickSuggestion('{{ $suggestion }}')" type="button"
class="px-2 py-1 bg-purple/10 border border-purple/20 text-purple text-xs rounded font-mono hover:bg-purple/20 transition-colors">
{{ $suggestion }}
</button>
@endforeach
</div>
@endif
@error('slug') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
</div>
@ -29,11 +59,18 @@
</div>
<div class="flex gap-3 mt-6">
<button wire:click="save"
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Create Link
<button wire:click="save" wire:loading.attr="disabled" wire:target="save"
class="flex-1 px-4 py-2.5 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity disabled:opacity-60">
<span wire:loading.remove wire:target="save">Create Link</span>
<span wire:loading wire:target="save" class="flex items-center justify-center gap-2">
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Creating...
</span>
</button>
<button wire:click="closeModal"
<button wire:click="closeModal" type="button"
class="px-4 py-2.5 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
Cancel
</button>

View File

@ -1,39 +1,188 @@
<div class="p-6 space-y-5">
<h3 class="text-base font-semibold text-t1">Create QR Code</h3>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-0 max-h-[85vh]">
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Destination URL <span class="text-red">*</span></label>
<input wire:model="url" type="url" placeholder="https://example.com"
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
@error('url')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
</div>
{{-- LEFT: Form --}}
<div class="p-6 overflow-y-auto border-r border-white/[.06]">
<h2 class="text-lg font-semibold text-t1 mb-4">Create QR Code</h2>
<div>
<div class="mb-4">
<label class="block text-xs font-medium text-t2 mb-1.5">Label (optional)</label>
<input wire:model="label" type="text" placeholder="My QR Code"
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
@error('label')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
<input wire:model.live="label" type="text" placeholder="My QR Code"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50">
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Type</label>
<select wire:model="type"
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t2 focus:outline-none focus:ring-1 focus:ring-blue/50 cursor-pointer">
<option value="url">URL</option>
<option value="vcard">vCard</option>
<option value="wifi">WiFi</option>
<option value="pdf">PDF</option>
<option value="mp3">MP3</option>
<option value="text">Text</option>
<div class="flex gap-0.5 mb-4 border-b border-white/[.06] overflow-x-auto -mx-6 px-6">
@foreach(['url'=>'URL','vcard'=>'vCard','wifi'=>'WiFi','email'=>'Email','phone'=>'Phone','sms'=>'SMS','text'=>'Text'] as $key => $lbl)
<button wire:click="$set('type','{{ $key }}')" type="button"
class="px-3 py-2 text-xs whitespace-nowrap border-b-2 transition-colors -mb-px
{{ $type === $key ? 'border-blue text-blue font-medium' : 'border-transparent text-t2 hover:text-t1' }}">
{{ $lbl }}
</button>
@endforeach
</div>
<div class="space-y-3 mb-4">
@if(in_array($type, ['url','text','location']))
<input wire:model.live.debounce.250ms="url" type="{{ $type === 'url' ? 'url' : 'text' }}"
placeholder="{{ $type === 'url' ? 'https://example.com' : ($type === 'location' ? '48.8566,2.3522' : 'Your text') }}"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50">
@elseif($type === 'email')
<input wire:model.live.debounce.250ms="url" type="email" placeholder="email@example.com"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none">
@elseif($type === 'phone')
<input wire:model.live.debounce.250ms="url" type="tel" placeholder="+49 123 456789"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none">
@elseif($type === 'sms')
<input wire:model.live.debounce.250ms="url" type="tel" placeholder="+49 123 456789"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none">
@elseif($type === 'vcard')
<input wire:model.live="vcardName" placeholder="Full Name" class="block w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none">
<input wire:model.live="vcardPhone" placeholder="Phone" class="block w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none">
<input wire:model.live="vcardEmail" placeholder="Email" class="block w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none">
<input wire:model.live="vcardCompany" placeholder="Company" class="block w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none">
<input wire:model.live="vcardWebsite" placeholder="Website" class="block w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none">
@elseif($type === 'wifi')
<input wire:model.live="wifiSsid" placeholder="Network name (SSID)" class="block w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none">
<input wire:model.live="wifiPassword" type="password" placeholder="Password" class="block w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none">
<select wire:model.live="wifiEncryption" class="block w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 focus:outline-none">
<option value="WPA">WPA/WPA2</option>
<option value="WEP">WEP</option>
<option value="nopass">Open</option>
</select>
<label class="flex items-center gap-2 text-sm text-t2 cursor-pointer">
<input wire:model.live="wifiHidden" type="checkbox"> Hidden network
</label>
@endif
</div>
<details class="border border-white/[.06] rounded-lg mb-2">
<summary class="px-4 py-3 text-sm font-medium text-t1 cursor-pointer select-none">Styling</summary>
<div class="px-4 pb-4 space-y-3 mt-2">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-t2 mb-1">Foreground</label>
<input wire:model.live="fgColor" type="color" class="w-full h-9 rounded cursor-pointer border border-white/[.06]">
</div>
<div>
<label class="block text-xs text-t2 mb-1">Background</label>
<input wire:model.live="bgColor" type="color" class="w-full h-9 rounded cursor-pointer border border-white/[.06]">
</div>
</div>
<div>
<label class="block text-xs text-t2 mb-1">Error Correction</label>
<select wire:model.live="errorCorrection" class="block w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 focus:outline-none">
<option value="L">L 7%</option>
<option value="M">M 15%</option>
<option value="Q">Q 25%</option>
<option value="H">H 30%</option>
</select>
</div>
<div>
<label class="block text-xs text-t2 mb-1">Size: <span class="text-t1">{{ $size }}px</span></label>
<input wire:model.live="size" type="range" min="128" max="1024" step="32" class="w-full accent-blue">
</div>
<div>
<label class="block text-xs text-t2 mb-1">Logo URL (optional)</label>
<input wire:model.live.debounce.500ms="logoUrl" type="url" placeholder="https://..."
class="block w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none">
</div>
</div>
</details>
<details class="border border-white/[.06] rounded-lg mb-2">
<summary class="px-4 py-3 text-sm font-medium text-t1 cursor-pointer select-none">Frame</summary>
<div class="px-4 pb-4 space-y-3 mt-2">
<select wire:model.live="frame" class="block w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 focus:outline-none">
<option value="none">No frame</option>
<option value="label-bottom">Label bottom</option>
<option value="label-top">Label top</option>
</select>
<input wire:model.live="frameText" placeholder='e.g. "Scan me"'
class="block w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none">
<div>
<label class="block text-xs text-t2 mb-1">Frame Color</label>
<input wire:model.live="frameColor" type="color" class="w-full h-9 rounded cursor-pointer border border-white/[.06]">
</div>
</div>
</details>
<details class="border border-white/[.06] rounded-lg mb-4">
<summary class="px-4 py-3 text-sm font-medium text-t1 cursor-pointer select-none">Advanced</summary>
<div class="px-4 pb-4 space-y-3 mt-2">
<label class="flex items-center gap-2 text-sm text-t2 cursor-pointer">
<input wire:model.live="isDynamic" type="checkbox"> Dynamic QR (target changeable later)
</label>
<div>
<label class="block text-xs text-t2 mb-1">Scan limit (optional)</label>
<input wire:model.live="scanLimit" type="number" min="1" placeholder="Unlimited"
class="block w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none">
</div>
<div>
<label class="block text-xs text-t2 mb-1">Expires at (optional)</label>
<input wire:model.live="expiresAt" type="datetime-local"
class="block w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 focus:outline-none">
</div>
</div>
</details>
<div class="flex gap-3">
<button wire:click="save" wire:loading.attr="disabled" wire:target="save"
class="flex-1 px-4 py-2.5 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90 disabled:opacity-60 transition-opacity">
<span wire:loading.remove wire:target="save">Create QR Code</span>
<span wire:loading wire:target="save">Creating...</span>
</button>
<button wire:click="closeModal" type="button"
class="px-4 py-2.5 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
Cancel
</button>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
<x-ui.button wire:click="save" action="save" label="Create QR Code" loading-label="Creating..." />
{{-- RIGHT: Live Preview --}}
<div class="bg-s2 p-6 flex flex-col items-center justify-center">
<div class="text-xs font-medium text-t3 uppercase tracking-wider mb-4">Live Preview</div>
<div id="qr-preview-wrap" wire:ignore class="rounded-xl p-4 shadow-inner flex items-center justify-center"
style="background: {{ $bgColor }}; min-width: 220px; min-height: 220px;">
<canvas id="qr-canvas" style="display:none;"></canvas>
<span id="qr-placeholder" class="text-xs text-gray-400">Enter content above</span>
</div>
<div class="text-xs text-t3 mt-3">{{ $size }}×{{ $size }}px · {{ strtoupper($errorCorrection) }}</div>
</div>
</div>
@script
<script>
import QRCode from 'qrcode';
const canvas = document.getElementById('qr-canvas');
const placeholder = document.getElementById('qr-placeholder');
const wrap = document.getElementById('qr-preview-wrap');
async function renderQR(payload, options) {
const empty = !payload || ['mailto:', 'tel:', 'sms:', 'geo:', ''].includes(payload.trim());
if (empty) {
canvas.style.display = 'none';
placeholder.style.display = 'block';
return;
}
try {
placeholder.style.display = 'none';
canvas.style.display = 'block';
wrap.style.background = options.bgColor || '#ffffff';
await QRCode.toCanvas(canvas, payload, {
width: Math.min(220, options.size || 220),
margin: options.margin || 4,
errorCorrectionLevel: options.errorCorrection || 'M',
color: { dark: options.fgColor || '#000000', light: options.bgColor || '#ffffff' },
});
} catch(e) { console.warn('QR:', e); }
}
$wire.on('qr-preview-update', ({ payload, options }) => renderQR(payload, options));
renderQR(@json($this->buildPayload()), {
size: @json($size), margin: @json($margin),
errorCorrection: @json($errorCorrection),
fgColor: @json($fgColor), bgColor: @json($bgColor),
});
</script>
@endscript

View File

@ -19,7 +19,7 @@
<p class="text-sm font-medium text-green mb-2">Token created copy it now, it won't be shown again</p>
<code class="block font-mono text-xs text-t1 bg-s3 px-3 py-2 rounded-lg break-all">{{ $newToken }}</code>
</div>
<button wire:click="dismissNewToken" class="text-t3 hover:text-t1 flex-shrink-0 text-sm"></button>
<button wire:click="dismissNewToken" class="text-t3 hover:text-t1 flex-shrink-0"><x-heroicon-o-x-mark class="w-4 h-4" /></button>
</div>
</div>
@endif
@ -31,7 +31,7 @@
<input wire:model="tokenName" type="text" placeholder="Token name"
class="flex-1 px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50">
<button wire:click="createToken"
class="px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 flex-shrink-0">
class="px-4 py-2.5 bg-accent-gradient text-white text-sm font-medium rounded-lg hover:opacity-90 flex-shrink-0">
Create
</button>
</div>