From 5df2a106d78b49a808a8ee0392c08bec43746294 Mon Sep 17 00:00:00 2001 From: CodeWithDennis Date: Sun, 26 Jul 2026 12:25:50 +0200 Subject: [PATCH 1/2] feat: add theme toggle and thumbnail labels Support light/dark/auto UI themes and overlay left/right/badge labels on exports. --- resources/css/app.css | 26 +++++- resources/js/app.js | 98 ++++++++++++++++++++- resources/js/compositor.js | 82 ++++++++++++++++++ resources/views/app.blade.php | 159 +++++++++++++++++++++++++++++++--- 4 files changed, 349 insertions(+), 16 deletions(-) diff --git a/resources/css/app.css b/resources/css/app.css index e65946c..cbf1e6b 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -50,6 +50,9 @@ @layer theme { .dark { --color-lumis-canvas: #0c0c0b; + --color-lumis-ink: #f3f2f0; + --color-lumis-display: #f5f4f2; + --color-lumis-icon: #9a9691; --color-lumis-panel-surface: #121111; --color-lumis-panel-header: #1a1817; --color-lumis-panel-line: #32302e; @@ -68,6 +71,11 @@ @layer base { html { font-size: 110%; + color-scheme: light; + } + + html.dark { + color-scheme: dark; } body { @@ -104,20 +112,30 @@ -moz-appearance: textfield; } - input.preset-name-input { + input.preset-name-input, + input.label-text-input { border: 1px solid var(--color-lumis-panel-line); background: var(--color-lumis-segment-idle); color: var(--color-lumis-ink); outline: none; } - input.preset-name-input::placeholder { + input.label-text-input { + height: 2rem; + padding: 0 0.625rem; + font-size: 0.75rem; + line-height: 1rem; + } + + input.preset-name-input::placeholder, + input.label-text-input::placeholder { color: #a3a3a3; } - input.preset-name-input:focus { + input.preset-name-input:focus, + input.label-text-input:focus { border-color: var(--color-lumis-panel-line); - background: #ffffff; + background: var(--color-lumis-control-bg); } input.slider-value:focus { diff --git a/resources/js/app.js b/resources/js/app.js index 8be03f4..655c67a 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -2,6 +2,7 @@ import Alpine from 'alpinejs'; import { createCompositor } from './compositor.js'; const PRESETS_STORAGE_KEY = 'pairframe.presets'; +const THEME_STORAGE_KEY = 'pairframe.theme'; document.addEventListener('alpine:init', () => { Alpine.data('pairframe', () => ({ @@ -26,6 +27,21 @@ document.addEventListener('alpine:init', () => { imagePadding: 0, imageRadius: 0, + labelsEnabled: false, + labelLeft: 'Left', + labelRight: 'Right', + labelBadge: '', + labelLeftPosition: 'bottom', + labelRightPosition: 'bottom', + labelBadgePosition: 'top', + labelSize: 100, + + labelPositions: [ + { id: 'top', label: 'Top', labelHorizontal: 'Left' }, + { id: 'middle', label: 'Middle', labelHorizontal: 'Center' }, + { id: 'bottom', label: 'Bottom', labelHorizontal: 'Right' }, + ], + backgroundType: 'dots', backgroundBg: '#FAFAFA', backgroundFg: '#E5E5E5', @@ -37,6 +53,7 @@ document.addEventListener('alpine:init', () => { jpgQuality: 92, exporting: false, statusMessage: '', + theme: 'auto', presets: [], presetName: '', @@ -78,6 +95,16 @@ document.addEventListener('alpine:init', () => { init() { this.compositor = createCompositor(); + const storedTheme = localStorage.getItem(THEME_STORAGE_KEY); + this.theme = storedTheme === 'light' || storedTheme === 'dark' || storedTheme === 'auto' ? storedTheme : 'auto'; + this.applyTheme(); + this._themeMedia = window.matchMedia('(prefers-color-scheme: dark)'); + this._onThemeMedia = () => { + if (this.theme === 'auto') { + this.applyTheme(); + } + }; + this._themeMedia.addEventListener('change', this._onThemeMedia); this.loadPresets(); this.$watch( () => [ @@ -89,6 +116,14 @@ document.addEventListener('alpine:init', () => { this.swapSides, this.imagePadding, this.imageRadius, + this.labelsEnabled, + this.labelLeft, + this.labelRight, + this.labelBadge, + this.labelLeftPosition, + this.labelRightPosition, + this.labelBadgePosition, + this.labelSize, this.backgroundType, this.backgroundBg, this.backgroundFg, @@ -106,6 +141,19 @@ document.addEventListener('alpine:init', () => { window.addEventListener('resize', this._onResize); }, + setTheme(theme) { + this.theme = theme; + localStorage.setItem(THEME_STORAGE_KEY, theme); + this.applyTheme(); + }, + + applyTheme() { + const dark = + this.theme === 'dark' || + (this.theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches); + document.documentElement.classList.toggle('dark', dark); + }, + get canExport() { return Boolean(this.imageA && this.imageB) && (this.exportPng || this.exportJpg); }, @@ -138,8 +186,8 @@ document.addEventListener('alpine:init', () => { segmentClass(active) { return active - ? 'bg-lumis-ink text-white' - : 'bg-lumis-segment-idle text-lumis-ink hover:bg-zinc-200'; + ? 'bg-lumis-ink text-lumis-canvas' + : 'bg-lumis-segment-idle text-lumis-ink hover:bg-zinc-200 dark:hover:bg-zinc-800'; }, clampSlider(key, min, max) { @@ -167,6 +215,14 @@ document.addEventListener('alpine:init', () => { swapSides: this.swapSides, imagePadding: this.imagePadding, imageRadius: this.imageRadius, + labelsEnabled: this.labelsEnabled, + labelLeft: this.labelLeft, + labelRight: this.labelRight, + labelBadge: this.labelBadge, + labelLeftPosition: this.labelLeftPosition, + labelRightPosition: this.labelRightPosition, + labelBadgePosition: this.labelBadgePosition, + labelSize: this.labelSize, backgroundType: this.backgroundType, backgroundBg: this.backgroundBg, backgroundFg: this.backgroundFg, @@ -209,6 +265,31 @@ document.addEventListener('alpine:init', () => { if (Number.isFinite(Number(settings.imageRadius))) { this.imageRadius = Math.min(50, Math.max(0, Number(settings.imageRadius))); } + if (typeof settings.labelsEnabled === 'boolean') { + this.labelsEnabled = settings.labelsEnabled; + } + if (typeof settings.labelLeft === 'string') { + this.labelLeft = settings.labelLeft; + } + if (typeof settings.labelRight === 'string') { + this.labelRight = settings.labelRight; + } + if (typeof settings.labelBadge === 'string') { + this.labelBadge = settings.labelBadge; + } + const positions = new Set(this.labelPositions.map((item) => item.id)); + if (positions.has(settings.labelLeftPosition)) { + this.labelLeftPosition = settings.labelLeftPosition; + } + if (positions.has(settings.labelRightPosition)) { + this.labelRightPosition = settings.labelRightPosition; + } + if (positions.has(settings.labelBadgePosition)) { + this.labelBadgePosition = settings.labelBadgePosition; + } + if (Number.isFinite(Number(settings.labelSize))) { + this.labelSize = Math.min(160, Math.max(50, Number(settings.labelSize))); + } if (backgrounds.has(settings.backgroundType)) { this.backgroundType = settings.backgroundType; } @@ -300,6 +381,9 @@ document.addEventListener('alpine:init', () => { if (Number(settings.imageRadius) > 0) { parts.push(`radius ${Math.round(Number(settings.imageRadius))}%`); } + if (settings.labelsEnabled) { + parts.push('labels'); + } if (settings.backgroundType) { parts.push(String(settings.backgroundType)); } @@ -529,6 +613,16 @@ document.addEventListener('alpine:init', () => { diagonalAngle: this.diagonalAngle, imagePadding: this.imagePadding, imageRadius: this.imageRadius, + labels: { + enabled: this.labelsEnabled, + left: this.labelLeft, + right: this.labelRight, + badge: this.labelBadge, + leftPosition: this.labelLeftPosition, + rightPosition: this.labelRightPosition, + badgePosition: this.labelBadgePosition, + size: this.labelSize, + }, background: { type: this.backgroundType, bg: this.backgroundBg, diff --git a/resources/js/compositor.js b/resources/js/compositor.js index 609c2e2..060c7ec 100644 --- a/resources/js/compositor.js +++ b/resources/js/compositor.js @@ -249,9 +249,91 @@ export function createCompositor() { renderSplit(options, width, height, sideA || sideB, sideB || sideA); } + paintLabels(ctx, width, height, options); + return canvas; } + function paintLabels(ctx, width, height, options) { + const labels = options.labels; + if (!labels?.enabled) { + return; + } + + const leftText = String(labels.left || '').trim(); + const rightText = String(labels.right || '').trim(); + const badgeText = String(labels.badge || '').trim(); + if (!leftText && !rightText && !badgeText) { + return; + } + + const family = options.layoutFamily || options.layout || 'vertical'; + const leftPosition = labels.leftPosition || 'bottom'; + const rightPosition = labels.rightPosition || 'bottom'; + const badgePosition = labels.badgePosition || 'top'; + const scale = clamp(Number(labels.size) || 100, 50, 160) / 100; + const fontSize = Math.max(11, Math.round(Math.min(width, height) * 0.032 * scale)); + const inset = Math.max(10, Math.round(Math.min(width, height) * 0.025)); + const content = contentPadding(width, height, options); + const horizontal = family === 'horizontal' || (family === 'fade' && (options.layoutVariant || '') === 'tb'); + + const axisPoint = (start, size, position) => { + if (position === 'top') { + return start + inset + fontSize * 0.9; + } + if (position === 'bottom') { + return start + size - inset - fontSize * 0.9; + } + return start + size / 2; + }; + + const drawPill = (text, x, y, align = 'center') => { + if (!text) { + return; + } + + ctx.save(); + ctx.font = `600 ${fontSize}px Inter, ui-sans-serif, system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const textWidth = ctx.measureText(text).width; + const padX = fontSize * 0.72; + const padY = fontSize * 0.42; + const pillW = textWidth + padX * 2; + const pillH = fontSize + padY * 2; + let left = x - pillW / 2; + if (align === 'left') { + left = x; + } else if (align === 'right') { + left = x - pillW; + } + const topY = y - pillH / 2; + + ctx.fillStyle = 'rgba(23, 23, 23, 0.72)'; + roundRectPath(ctx, left, topY, pillW, pillH, pillH / 2); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.fillText(text, left + pillW / 2, y + 0.5); + ctx.restore(); + }; + + if (horizontal) { + const topY = content.y + inset + fontSize * 0.9; + const bottomY = content.y + content.height - inset - fontSize * 0.9; + const leftAlign = leftPosition === 'top' ? 'left' : leftPosition === 'bottom' ? 'right' : 'center'; + const rightAlign = rightPosition === 'top' ? 'left' : rightPosition === 'bottom' ? 'right' : 'center'; + drawPill(leftText, axisPoint(content.x, content.width, leftPosition), topY, leftAlign); + drawPill(rightText, axisPoint(content.x, content.width, rightPosition), bottomY, rightAlign); + } else { + drawPill(leftText, content.x + inset, axisPoint(content.y, content.height, leftPosition), 'left'); + drawPill(rightText, content.x + content.width - inset, axisPoint(content.y, content.height, rightPosition), 'right'); + } + + if (badgeText) { + drawPill(badgeText, content.x + content.width / 2, axisPoint(content.y, content.height, badgePosition), 'center'); + } + } + function drawPreview(previewCanvas) { if (!previewCanvas || !canvas.width) { return { scale: 1, offsetX: 0, offsetY: 0, drawWidth: 0, drawHeight: 0 }; diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 0f14bce..3eff1aa 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -5,6 +5,18 @@ {{ config('app.name', 'Pairframe') }} + @fonts @vite(['resources/css/app.css', 'resources/js/app.js'])