Files
Atomcms-edit/resources/themes/atom/views/draw-badge.blade.php
T
2026-05-09 17:32:17 +02:00

1342 lines
50 KiB
PHP
Executable File

<x-app-layout>
@push('title', __('Badge Generator'))
<script>
const translations = {
buy_confirmation: @json(__('badge_purchase_confirmation', ['cost' => $cost, 'currency' => $currencyType])),
purchase_success: @json(__('badge_purchase_success', ['currency' => ucfirst($currencyType)])),
purchase_error_insufficient: @json(__('badge_purchase_error_insufficient', ['currency' => $currencyType])),
purchase_error_general: @json(__('badge_purchase_error_general')),
missing_fields: @json(__('Please fill in the badge name, description, and draw something on the canvas.')),
invalid_content: @json(__('Badge name and description cannot contain URLs.')),
invalid_file_type: @json(__('Only PNG and GIF files are allowed.'))
};
</script>
<div class="col-span-12 flex flex-col lg:grid grid-cols-4 gap-4" x-data="badgeDrawer({ cost: {{ $cost }}, currencyType: '{{ $currencyType }}' })">
<x-content.content-card icon="hotel-icon" classes="border dark:border-gray-900 col-span-3">
<x-slot:title>
{{ __('Badge Drawer') }}
</x-slot:title>
<x-slot:under-title>
{{ __('Draw your very own badge') }}
</x-slot:under-title>
<div class="px-2 text-sm dark:text-gray-200">
@if ($folderError)
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
<strong class="font-bold">Error:</strong>
<span class="block sm:inline">{{ $errorMessage }}</span>
</div>
@endif
<div class="flex flex-col gap-6">
<!-- Tools Section -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 space-y-4">
<!-- Drawing Tools -->
<div>
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">{{ __('Drawing Tools') }}</p>
<div class="grid grid-cols-4 md:grid-cols-8 gap-2">
<button @click="toggleCopyMode" :aria-pressed="copyMode"
class="group h-14 flex flex-col items-center justify-center gap-1 px-2 rounded-xl border-2 transition-all duration-200 hover:scale-105 relative"
:class="[copyMode ? 'bg-[var(--color-primary)] border-[var(--color-primary)] text-white shadow-lg scale-105' : 'border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] dark:text-gray-100 bg-white dark:bg-gray-700', effectsEnabled ? 'btn-sparkle btn-float' : '']">
<i class="fa-solid fa-eye-dropper text-lg"></i>
<span class="text-[10px] font-medium">{{ __('Copy') }}</span>
<template x-if="effectsEnabled">
<div>
<span class="sparkle-1"></span>
<span class="sparkle-2"></span>
<span class="sparkle-3"></span>
</div>
</template>
</button>
<button @click="toggleEraseMode" :aria-pressed="eraseMode"
class="group h-14 flex flex-col items-center justify-center gap-1 px-2 rounded-xl border-2 transition-all duration-200 hover:scale-105 relative"
:class="[eraseMode ? 'bg-[var(--color-primary)] border-[var(--color-primary)] text-white shadow-lg scale-105' : 'border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] dark:text-gray-100 bg-white dark:bg-gray-700', effectsEnabled ? 'btn-sparkle btn-float' : '']">
<i class="fa-solid fa-eraser text-lg"></i>
<span class="text-[10px] font-medium">{{ __('Erase') }}</span>
<template x-if="effectsEnabled">
<div>
<span class="sparkle-1"></span>
<span class="sparkle-2"></span>
<span class="sparkle-3"></span>
</div>
</template>
</button>
<button @click="toggleFillMode" :aria-pressed="fillMode"
class="group h-14 flex flex-col items-center justify-center gap-1 px-2 rounded-xl border-2 transition-all duration-200 hover:scale-105 relative"
:class="[fillMode ? 'bg-[var(--color-primary)] border-[var(--color-primary)] text-white shadow-lg scale-105' : 'border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] dark:text-gray-100 bg-white dark:bg-gray-700', effectsEnabled ? 'btn-sparkle btn-float' : '']">
<i class="fa-solid fa-fill text-lg"></i>
<span class="text-[10px] font-medium">{{ __('Fill') }}</span>
<template x-if="effectsEnabled">
<div>
<span class="sparkle-1"></span>
<span class="sparkle-2"></span>
<span class="sparkle-3"></span>
</div>
</template>
</button>
<button @click="toggleShapeMode('rectangle')" :aria-pressed="shapeMode === 'rectangle'"
class="group h-14 flex flex-col items-center justify-center gap-1 px-2 rounded-xl border-2 transition-all duration-200 hover:scale-105 relative"
:class="[shapeMode === 'rectangle' ? 'bg-[var(--color-primary)] border-[var(--color-primary)] text-white shadow-lg scale-105' : 'border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] dark:text-gray-100 bg-white dark:bg-gray-700', effectsEnabled ? 'btn-sparkle btn-float' : '']">
<i class="fa-regular fa-square text-lg"></i>
<span class="text-[10px] font-medium">{{ __('Rect') }}</span>
<template x-if="effectsEnabled">
<div>
<span class="sparkle-1"></span>
<span class="sparkle-2"></span>
<span class="sparkle-3"></span>
</div>
</template>
</button>
<button @click="toggleShapeMode('circle')" :aria-pressed="shapeMode === 'circle'"
class="group h-14 flex flex-col items-center justify-center gap-1 px-2 rounded-xl border-2 transition-all duration-200 hover:scale-105 relative"
:class="[shapeMode === 'circle' ? 'bg-[var(--color-primary)] border-[var(--color-primary)] text-white shadow-lg scale-105' : 'border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] dark:text-gray-100 bg-white dark:bg-gray-700', effectsEnabled ? 'btn-sparkle btn-float' : '']">
<i class="fa-regular fa-circle text-lg"></i>
<span class="text-[10px] font-medium">{{ __('Circle') }}</span>
<template x-if="effectsEnabled">
<div>
<span class="sparkle-1"></span>
<span class="sparkle-2"></span>
<span class="sparkle-3"></span>
</div>
</template>
</button>
<button @click="toggleShapeMode('line')" :aria-pressed="shapeMode === 'line'"
class="group h-14 flex flex-col items-center justify-center gap-1 px-2 rounded-xl border-2 transition-all duration-200 hover:scale-105 relative"
:class="[shapeMode === 'line' ? 'bg-[var(--color-primary)] border-[var(--color-primary)] text-white shadow-lg scale-105' : 'border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] dark:text-gray-100 bg-white dark:bg-gray-700', effectsEnabled ? 'btn-sparkle btn-float' : '']">
<i class="fa-solid fa-minus text-lg"></i>
<span class="text-[10px] font-medium">{{ __('Line') }}</span>
<template x-if="effectsEnabled">
<div>
<span class="sparkle-1"></span>
<span class="sparkle-2"></span>
<span class="sparkle-3"></span>
</div>
</template>
</button>
<button @click="toggleTextMode()" :aria-pressed="textMode"
class="group h-14 flex flex-col items-center justify-center gap-1 px-2 rounded-xl border-2 transition-all duration-200 hover:scale-105 relative"
:class="[textMode ? 'bg-[var(--color-primary)] border-[var(--color-primary)] text-white shadow-lg scale-105' : 'border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] dark:text-gray-100 bg-white dark:bg-gray-700', effectsEnabled ? 'btn-sparkle btn-float' : '']">
<i class="fa-solid fa-font text-lg"></i>
<span class="text-[10px] font-medium">{{ __('Text') }}</span>
<template x-if="effectsEnabled">
<div>
<span class="sparkle-1"></span>
<span class="sparkle-2"></span>
<span class="sparkle-3"></span>
</div>
</template>
</button>
<button @click="effectsEnabled = !effectsEnabled"
class="group h-14 flex flex-col items-center justify-center gap-1 px-2 rounded-xl border-2 transition-all duration-200 hover:scale-105"
:class="effectsEnabled ? 'bg-purple-500 border-purple-500 text-white shadow-lg scale-105 btn-glow' : 'border-gray-300 dark:border-gray-600 hover:border-purple-500 dark:text-gray-100 bg-white dark:bg-gray-700'">
<i class="fa-solid fa-wand-magic-sparkles text-lg"></i>
<span class="text-[10px] font-medium">{{ __('FX') }}</span>
</button>
</div>
</div>
<!-- Text Input Modal -->
<div x-show="textMode && textModalOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
class="bg-white dark:bg-gray-700 rounded-xl p-4 shadow-lg border-2 border-[var(--color-primary)] space-y-3"
@click.away="textModalOpen = false">
<p class="text-sm font-semibold dark:text-gray-100">{{ __('Add Text') }}</p>
<input type="text" x-model="textInput" placeholder="{{ __('Enter text') }}" maxlength="3"
class="w-full px-3 py-2 rounded-lg border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-[var(--color-primary)] transition-all">
<div class="flex gap-2">
<select x-model="textSize" class="px-2 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm dark:text-gray-100">
<option value="1">{{ __('Tiny') }}</option>
<option value="2">{{ __('Small') }}</option>
<option value="3">{{ __('Medium') }}</option>
</select>
<button @click="addText()"
class="flex-1 px-3 py-1.5 rounded-lg bg-[var(--color-primary)] text-white text-sm font-medium hover:brightness-110 transition-all">
{{ __('Add') }}
</button>
<button @click="textModalOpen = false; textMode = false"
class="px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-sm dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-600 transition-all">
{{ __('Cancel') }}
</button>
</div>
</div>
<!-- View Tools -->
<div class="flex flex-wrap items-center gap-4">
<div class="flex-1 min-w-[200px]">
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">{{ __('View') }}</p>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1 bg-white dark:bg-gray-700 rounded-xl border border-gray-300 dark:border-gray-600 p-1.5 shadow-sm">
<button @click="zoomOut" class="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-all hover:scale-110" title="{{ __('Zoom Out') }}">
<i class="fa-solid fa-magnifying-glass-minus text-lg"></i>
</button>
<span class="w-14 text-center text-sm font-bold dark:text-gray-200" x-text="zoomLevel + '%'"></span>
<button @click="zoomIn" class="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-all hover:scale-110" title="{{ __('Zoom In') }}">
<i class="fa-solid fa-magnifying-glass-plus text-lg"></i>
</button>
</div>
<button @click="showGrid = !showGrid"
class="h-11 px-4 flex items-center gap-2 rounded-xl border-2 transition-all duration-200 text-sm font-medium hover:scale-105"
:class="showGrid ? 'bg-[var(--color-primary)] border-[var(--color-primary)] text-white shadow-lg' : 'border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] dark:text-gray-100 bg-white dark:bg-gray-700'">
<i class="fa-solid fa-border-all text-lg"></i>
<span class="hidden sm:inline">{{ __('Grid') }}</span>
</button>
</div>
</div>
<div class="flex-1 min-w-[200px]">
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">{{ __('Actions') }}</p>
<div class="flex items-center gap-2">
<button @click="undo" :disabled="undoStack.length === 0"
class="flex-1 h-11 flex items-center justify-center gap-2 rounded-xl border-2 transition-all duration-200 text-sm font-medium"
:class="undoStack.length === 0 ? 'border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-600 cursor-not-allowed' : 'border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] dark:text-gray-100 hover:shadow-md bg-white dark:bg-gray-700'">
<i class="fa-solid fa-arrow-rotate-left text-lg"></i>
<span>{{ __('Undo') }}</span>
</button>
<button @click="redo" :disabled="redoStack.length === 0"
class="flex-1 h-11 flex items-center justify-center gap-2 rounded-xl border-2 transition-all duration-200 text-sm font-medium"
:class="redoStack.length === 0 ? 'border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-600 cursor-not-allowed' : 'border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] dark:text-gray-100 hover:shadow-md bg-white dark:bg-gray-700'">
<i class="fa-solid fa-arrow-rotate-right text-lg"></i>
<span>{{ __('Redo') }}</span>
</button>
<button @click="$refs.fileInput.click()"
class="h-11 px-4 flex items-center gap-2 rounded-xl border-2 border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] transition-all duration-200 text-sm font-medium dark:text-gray-100 bg-white dark:bg-gray-700 hover:shadow-md">
<i class="fa-solid fa-upload text-lg"></i>
<span class="hidden sm:inline">{{ __('Import') }}</span>
</button>
</div>
</div>
</div>
</div>
<input type="file" accept="image/png,image/gif" x-ref="fileInput" style="display:none;" @change="importImage($event)">
<div class="flex flex-col md:flex-row gap-4 md:gap-8 items-start overflow-auto">
<div class="checkerboard w-full max-w-[640px] mx-auto aspect-square relative" :style="'transform: scale(' + zoomLevel/100 + '); transform-origin: center;'">
<div id="guide" x-ref="guide"></div>
<canvas width="40" height="40" x-ref="canvas" class="w-full h-full border border-gray-300 dark:border-gray-700" style="image-rendering: pixelated; background: transparent; role="img" aria-label="{{ __('Badge drawing area') }}"></canvas>
</div>
</div>
<!-- Colors Section -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 space-y-4">
<!-- Current Color -->
<div>
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">{{ __('Current Color') }}</p>
<div class="flex items-center gap-3">
<button @click="$refs.colorInput.click()" class="w-14 h-14 rounded-xl border-2 border-gray-300 dark:border-gray-600 shadow-inner flex items-center justify-center relative overflow-hidden transition-all hover:scale-105" :style="'background-color: ' + color">
<i class="fa-solid fa-fill-drip text-white -md text-xl"></i>
<input type="color" id="colorInput" x-ref="colorInput" x-model="color" class="absolute opacity-0 w-full h-full cursor-pointer" @input="color = $event.target.value">
</button>
<div class="text-sm">
<p class="font-medium dark:text-gray-200">{{ __('Click to change color') }}</p>
<p class="text-gray-500 dark:text-gray-400 font-mono" x-text="color.toUpperCase()"></p>
</div>
</div>
</div>
<!-- Color Palette -->
<div>
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">{{ __('Color Palette') }}</p>
<div class="flex flex-wrap gap-1.5">
<template x-for="col in colors" :key="col">
<button @click="color = col; eraseMode = false" :style="'background-color: ' + col" class="w-8 h-8 rounded-lg border-2 border-transparent hover:border-white hover:scale-110 transition-all shadow-sm" :class="color === col ? 'ring-2 ring-[var(--color-primary)] ring-offset-2' : ''"></button>
</template>
</div>
</div>
<!-- Recent Colors -->
<div>
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">{{ __('Recent Colors') }}</p>
<div class="flex flex-wrap gap-1.5 min-h-[40px]">
<template x-for="col in recentColors" :key="col">
<button @click="color = col; eraseMode = false" :style="'background-color: ' + col" class="w-8 h-8 rounded-lg border-2 border-transparent hover:border-white hover:scale-110 transition-all shadow-sm" :title="col"></button>
</template>
<template x-for="i in (12 - recentColors.length)" :key="'empty-' + i">
<div class="w-8 h-8 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600"></div>
</template>
</div>
</div>
<!-- Color Themes -->
<div>
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">{{ __('Color Themes') }}</p>
<div class="flex flex-wrap gap-2">
<button @click="setPalette('classic')" class="px-3 py-2 text-xs rounded-lg border border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] hover:bg-gray-50 dark:hover:bg-gray-700 transition-all duration-200 dark:text-gray-200 font-medium">{{ __('Classic') }}</button>
<button @click="setPalette('neon')" class="px-3 py-2 text-xs rounded-lg border border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] hover:bg-gray-50 dark:hover:bg-gray-700 transition-all duration-200 dark:text-gray-200 font-medium">{{ __('Neon') }}</button>
<button @click="setPalette('pastel')" class="px-3 py-2 text-xs rounded-lg border border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] hover:bg-gray-50 dark:hover:bg-gray-700 transition-all duration-200 dark:text-gray-200 font-medium">{{ __('Pastel') }}</button>
<button @click="setPalette('earth')" class="px-3 py-2 text-xs rounded-lg border border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] hover:bg-gray-50 dark:hover:bg-gray-700 transition-all duration-200 dark:text-gray-200 font-medium">{{ __('Earth') }}</button>
<button @click="setPalette('ocean')" class="px-3 py-2 text-xs rounded-lg border border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] hover:bg-gray-50 dark:hover:bg-gray-700 transition-all duration-200 dark:text-gray-200 font-medium">{{ __('Ocean') }}</button>
<button @click="setPalette('warm')" class="px-3 py-2 text-xs rounded-lg border border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] hover:bg-gray-50 dark:hover:bg-gray-700 transition-all duration-200 dark:text-gray-200 font-medium">{{ __('Warm') }}</button>
<button @click="setPalette('cool')" class="px-3 py-2 text-xs rounded-lg border border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] hover:bg-gray-50 dark:hover:bg-gray-700 transition-all duration-200 dark:text-gray-200 font-medium">{{ __('Cool') }}</button>
<button @click="setPalette('rainbow')" class="px-3 py-2 text-xs rounded-lg border border-gray-300 dark:border-gray-600 hover:border-[var(--color-primary)] hover:bg-gray-50 dark:hover:bg-gray-700 transition-all duration-200 dark:text-gray-200 font-medium">{{ __('Rainbow') }}</button>
</div>
</div>
</div>
<!-- Actions Section -->
<div class="bg-white dark:bg-gray-800 rounded-xl p-5 space-y-4 border-2 border-gray-200 dark:border-gray-700 shadow-sm">
<div class="flex items-center gap-2">
<i class="fa-solid fa-bolt text-[var(--color-primary)]"></i>
<p class="text-sm font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">{{ __('Badge Actions') }}</p>
</div>
<div class="grid grid-cols-2 gap-4">
<button type="button" @click="clearBoard" class="group flex flex-col items-center justify-center gap-2 px-4 py-4 rounded-xl bg-red-50 dark:bg-red-900/20 border-2 border-red-200 dark:border-red-800 hover:bg-red-100 dark:hover:bg-red-900/30 hover:border-red-300 dark:hover:border-red-700 transition-all duration-200">
<i class="fa-solid fa-trash-can text-2xl text-red-500 group-hover:scale-110 transition-transform"></i>
<span class="text-sm font-bold text-red-600 dark:text-red-400">{{ __('Clear Canvas') }}</span>
</button>
<button type="button" @click="generateCanvas('download')" class="group flex flex-col items-center justify-center gap-2 px-4 py-4 rounded-xl bg-[var(--color-primary)]/10 dark:bg-[var(--color-primary)]/20 border-2 border-[var(--color-primary)]/30 dark:border-[var(--color-primary)]/40 hover:bg-[var(--color-primary)]/20 dark:hover:bg-[var(--color-primary)]/30 transition-all duration-200">
<i class="fa-solid fa-cloud-arrow-down text-2xl text-[var(--color-primary)] group-hover:translate-y-1 transition-transform"></i>
<span class="text-sm font-bold text-[var(--color-primary)]">{{ __('Download Badge') }}</span>
</button>
</div>
</div>
</div>
</div>
</x-content.content-card>
<x-content.content-card icon="hotel-icon" classes="border dark:border-gray-900 col-span-1 h-max">
<x-slot:title>
{{ __('Badge Drawer Details') }}
</x-slot:title>
<x-slot:under-title>
{{ __('My Badge Details') }}
</x-slot:under-title>
<div class="px-2 text-sm">
<div class="flex flex-col gap-3">
<h3 class="font-semibold dark:text-gray-100">{{ __('Preview') }}</h3>
<div id="avatarbox" class="mx-auto">
<div class="username"
style="font-size: 12px;margin-top: 13px;margin-left: 30px;color: #FFF;">
{{ auth()->user()->username}}
</div>
<div class="avatara"
style="float: left;background: url('{{ setting('avatar_imager') }}{{ auth()->user()->look}}&direction=4&head_direction=3') no-repeat;width: 60px;height: 120px;margin-left: 15px;margin-top: 10px;">
</div>
<div class="preview" style='float: left;margin-left: 15px;margin-top: 7px;'>
<canvas width="40" height="40" x-ref="previewCanvas" style="image-rendering: pixelated; background: transparent;" role="img" aria-label="{{ __('Badge preview') }}"></canvas>
</div>
</div>
<div class="space-y-3">
<div>
<label for="badgeName" class="block text-sm font-semibold mb-1.5 dark:text-gray-100">{{ __('Badge Name') }}</label>
<input type="text" id="badgeName" x-model="badgeName" maxlength="24" placeholder="{{ __('Enter badge name') }}" class="w-full px-3 py-2 rounded-lg border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-[var(--color-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20 transition-all duration-200">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1" x-text="badgeName.length + '/24'"></p>
</div>
<div>
<label for="badgeDescription" class="block text-sm font-semibold mb-1.5 dark:text-gray-100">{{ __('Description') }}</label>
<input type="text" id="badgeDescription" x-model="badgeDescription" maxlength="255" placeholder="{{ __('Enter description') }}" class="w-full px-3 py-2 rounded-lg border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-[var(--color-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20 transition-all duration-200">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1" x-text="badgeDescription.length + '/255'"></p>
</div>
<button type="button" x-text="buttonText" @click="buyBadge" class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-semibold shadow-md transition-all duration-200 mt-4" :class="isValid && !{{ $folderError ? 'true' : 'false' }} ? 'bg-green-500 hover:bg-green-600 text-white hover:shadow-lg' : 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-not-allowed'" :disabled="!isValid || {{ $folderError ? 'true' : 'false' }}">
<i class="fa-solid fa-cart-shopping"></i>
<span x-text="buttonText"></span>
</button>
<p x-show="!isValid && (badgeName.length > 0 || badgeDescription.length > 0)" class="text-xs text-red-500 dark:text-red-400 text-center" x-cloak>
{{ __('Please fill in all fields and draw something') }}
</p>
</div>
</div>
</div>
</x-content.content-card>
</div>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.0/css/all.min.css" integrity="sha512-DxV+EoADOkOygM4IR9yXP8Sb2qwgidEmeqAEmDKIOfPRQZOWbXCzLC6vjbZyy0vPisbH2SyW27+ddLVCN+OMzQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="{{ asset('js/gif/gif.js') }}"></script>
<style>
@keyframes sparkle {
0%, 100% { transform: scale(0) rotate(0deg); opacity: 0; }
50% { transform: scale(1) rotate(180deg); opacity: 1; }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-3px); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 5px var(--color-primary), 0 0 10px var(--color-primary); }
50% { box-shadow: 0 0 15px var(--color-primary), 0 0 25px var(--color-primary); }
}
.btn-sparkle {
position: relative;
overflow: visible;
}
.btn-sparkle::before,
.btn-sparkle::after {
content: '✦';
position: absolute;
font-size: 12px;
color: #FFD700;
text-shadow: 0 0 5px #FFD700, 0 0 10px #FFA500;
pointer-events: none;
animation: sparkle 2s ease-in-out infinite;
opacity: 0;
}
.btn-sparkle::before {
top: -8px;
right: -5px;
animation-delay: 0s;
}
.btn-sparkle::after {
bottom: -5px;
left: -5px;
animation-delay: 1s;
}
.btn-sparkle .sparkle-1,
.btn-sparkle .sparkle-2,
.btn-sparkle .sparkle-3 {
position: absolute;
font-size: 8px;
color: #FFF;
text-shadow: 0 0 3px #FFF, 0 0 6px var(--color-primary);
pointer-events: none;
animation: sparkle 1.5s ease-in-out infinite;
opacity: 0;
}
.btn-sparkle .sparkle-1 {
top: 0;
left: 20%;
animation-delay: 0.3s;
}
.btn-sparkle .sparkle-2 {
top: 50%;
right: 5%;
animation-delay: 0.8s;
}
.btn-sparkle .sparkle-3 {
bottom: 10%;
left: 10%;
animation-delay: 1.2s;
}
.btn-float {
animation: float 3s ease-in-out infinite;
}
.btn-float:nth-child(2) { animation-delay: 0.2s; }
.btn-float:nth-child(3) { animation-delay: 0.4s; }
.btn-float:nth-child(4) { animation-delay: 0.6s; }
.btn-float:nth-child(5) { animation-delay: 0.8s; }
.btn-float:nth-child(6) { animation-delay: 1s; }
.btn-float:nth-child(7) { animation-delay: 1.2s; }
.btn-float:nth-child(8) { animation-delay: 1.4s; }
.btn-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
.star-icon {
position: absolute;
font-size: 10px;
animation: sparkle 2s ease-in-out infinite;
}
</style>
<script>
function badgeDrawer({ cost, currencyType }) {
return {
cost: cost,
currencyType: currencyType,
color: '#000000',
recentColors: [],
eraseMode: false,
copyMode: false,
fillMode: false,
shapeMode: null,
shapeStart: null,
textMode: false,
textModalOpen: false,
textInput: '',
textSize: 2,
effectsEnabled: false,
showGrid: true,
zoomLevel: 100,
minZoom: 100,
maxZoom: 400,
zoomStep: 50,
isDrawing: false,
cellSideCount: 40,
colorHistory: {},
undoStack: [],
redoStack: [],
maxUndoSize: 20,
badgeName: '',
badgeDescription: '',
lastBuyTime: null,
canBuy: true,
buttonText: `{{ __('Buy Badge') }} (${cost} ${currencyType.charAt(0).toUpperCase() + currencyType.slice(1)})`,
drawingContext: null,
previewContext: null,
guide: null,
canvas: null,
previewCanvas: null,
colors: [
'#000000', '#FFFFFF', '#808080', '#C0C0C0', '#FF0000', '#800000',
'#FFFF00', '#808000', '#00FF00', '#008000', '#00FFFF', '#008080',
'#0000FF', '#000080', '#FF00FF', '#800080', '#FF4500', '#FFA500',
'#FFD700', '#F0E68C', '#90EE90', '#98FB98', '#AFEEEE', '#ADD8E6',
'#87CEFA', '#6495ED', '#DDA0DD', '#EE82EE', '#A52A2A', '#D2691E',
'#CD853F', '#F4A460', '#FFC0CB', '#9370DB', '#228B22', '#20B2AA'
],
get isValid() {
return this.badgeName.trim().length > 0 && this.badgeName.trim().length <= 24 && this.badgeDescription.trim().length > 0 && this.badgeDescription.trim().length <= 255 && Object.keys(this.colorHistory).length > 0 && !this.badgeName.match(/https?:\/\/|www\./i) && !this.badgeDescription.match(/https?:\/\/|www\./i) && this.canBuy;
},
init() {
this.canvas = this.$refs.canvas;
this.previewCanvas = this.$refs.previewCanvas;
this.guide = this.$refs.guide;
this.drawingContext = this.canvas.getContext('2d');
this.previewContext = this.previewCanvas.getContext('2d');
// Get actual rendered size of the canvas
const canvasWidth = this.canvas.clientWidth;
const cellSize = canvasWidth / this.cellSideCount;
// Setup the guide with dynamic grid lines using gradients (no borders, no child divs)
this.guide.style.width = `${canvasWidth}px`;
this.guide.style.height = `${canvasWidth}px`;
// Clear any existing children (no longer needed)
this.guide.innerHTML = '';
// Determine border color based on dark mode
const isDark = document.documentElement.classList.contains('dark');
const borderColor = isDark ? '#fff' : '#4b5563'; // gray-700
// Set grid lines as background gradients
this.guide.style.backgroundImage = `
repeating-linear-gradient(to bottom, ${borderColor} 0px 1px, transparent 1px ${cellSize}px),
repeating-linear-gradient(to right, ${borderColor} 0px 1px, transparent 1px ${cellSize}px)
`;
// Watch for grid toggle (use block instead of grid)
this.$watch('showGrid', (value) => {
this.guide.style.display = value ? 'block' : 'none';
});
// Make checkerboard dynamic and aligned to cellSize
const checkerboardEl = this.$el.querySelector('.checkerboard');
let bgColor, checkColor;
if (isDark) {
bgColor = '#1a1a1a';
checkColor = '#2a2a2a';
} else {
bgColor = '#fff';
checkColor = '#eee';
}
const checkUnit = cellSize / 2; // Half cell for finer checks (2x2 per cell)
checkerboardEl.style.backgroundColor = bgColor;
checkerboardEl.style.backgroundImage = `
linear-gradient(45deg, ${checkColor} 25%, transparent 25%),
linear-gradient(-45deg, ${checkColor} 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, ${checkColor} 75%),
linear-gradient(-45deg, transparent 75%, ${checkColor} 75%)
`;
checkerboardEl.style.backgroundSize = `${checkUnit * 2}px ${checkUnit * 2}px`;
checkerboardEl.style.backgroundPosition = `0 0, 0 ${checkUnit}px, ${checkUnit}px -${checkUnit}px, -${checkUnit}px 0px`;
// Watch for erase/copy toggle
this.$watch('eraseMode', (value) => {
if (value) this.copyMode = false;
});
this.$watch('copyMode', (value) => {
if (value) this.eraseMode = false;
});
// Mouse events
this.canvas.addEventListener('mousedown', this.handleMousedown.bind(this));
this.canvas.addEventListener('mousemove', this.handleMousemove.bind(this));
this.canvas.addEventListener('mouseup', this.handleMouseup.bind(this));
this.canvas.addEventListener('mouseleave', () => { this.isDrawing = false; });
// Touch events
this.canvas.addEventListener('touchstart', this.handleTouchstart.bind(this));
this.canvas.addEventListener('touchmove', this.handleTouchmove.bind(this));
this.canvas.addEventListener('touchend', () => { this.isDrawing = false; });
this.canvas.addEventListener('touchcancel', () => { this.isDrawing = false; });
// Keyboard shortcuts for undo/redo
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault();
if (e.shiftKey) {
this.redo();
} else {
this.undo();
}
}
if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
e.preventDefault();
this.redo();
}
});
// Initial preview
this.updatePreview();
},
toggleCopyMode() {
this.copyMode = !this.copyMode;
if (this.copyMode) {
this.eraseMode = false;
}
},
toggleEraseMode() {
this.eraseMode = !this.eraseMode;
if (this.eraseMode) {
this.copyMode = false;
this.fillMode = false;
}
},
toggleFillMode() {
this.fillMode = !this.fillMode;
if (this.fillMode) {
this.eraseMode = false;
this.copyMode = false;
this.shapeMode = null;
this.textMode = false;
}
},
toggleTextMode() {
this.textMode = !this.textMode;
if (this.textMode) {
this.textModalOpen = true;
this.eraseMode = false;
this.copyMode = false;
this.fillMode = false;
this.shapeMode = null;
} else {
this.textModalOpen = false;
}
},
addText() {
if (!this.textInput.trim()) return;
this.saveState();
// Calculate center position
const centerX = Math.floor(this.canvas.width / 2);
const centerY = Math.floor(this.canvas.height / 2);
// Set font properties
const fontSize = parseInt(this.textSize) * 8;
this.drawingContext.font = `${fontSize}px Arial`;
this.drawingContext.fillStyle = this.color;
this.drawingContext.textAlign = 'center';
this.drawingContext.textBaseline = 'middle';
// Draw text
this.drawingContext.fillText(this.textInput.toUpperCase(), centerX, centerY);
// Update colorHistory
this.rebuildColorHistory();
this.updatePreview();
// Reset
this.textInput = '';
this.textModalOpen = false;
this.textMode = false;
},
saveState() {
// Save current canvas state to undo stack
const imageData = this.drawingContext.getImageData(0, 0, this.canvas.width, this.canvas.height);
this.undoStack.push({
imageData: imageData,
colorHistory: JSON.parse(JSON.stringify(this.colorHistory))
});
// Limit undo stack size
if (this.undoStack.length > this.maxUndoSize) {
this.undoStack.shift();
}
// Clear redo stack on new action
this.redoStack = [];
},
undo() {
if (this.undoStack.length === 0) return;
// Save current state to redo stack
const currentImageData = this.drawingContext.getImageData(0, 0, this.canvas.width, this.canvas.height);
this.redoStack.push({
imageData: currentImageData,
colorHistory: JSON.parse(JSON.stringify(this.colorHistory))
});
// Restore previous state
const previousState = this.undoStack.pop();
this.drawingContext.putImageData(previousState.imageData, 0, 0);
this.colorHistory = previousState.colorHistory;
this.updatePreview();
},
redo() {
if (this.redoStack.length === 0) return;
// Save current state to undo stack
const currentImageData = this.drawingContext.getImageData(0, 0, this.canvas.width, this.canvas.height);
this.undoStack.push({
imageData: currentImageData,
colorHistory: JSON.parse(JSON.stringify(this.colorHistory))
});
// Restore next state
const nextState = this.redoStack.pop();
this.drawingContext.putImageData(nextState.imageData, 0, 0);
this.colorHistory = nextState.colorHistory;
this.updatePreview();
},
toggleShapeMode(shape) {
if (this.shapeMode === shape) {
this.shapeMode = null;
} else {
this.shapeMode = shape;
this.eraseMode = false;
this.copyMode = false;
this.fillMode = false;
}
},
drawRectangle(x1, y1, x2, y2) {
const minX = Math.min(x1, x2);
const maxX = Math.max(x1, x2);
const minY = Math.min(y1, y2);
const maxY = Math.max(y1, y2);
for (let x = minX; x <= maxX; x++) {
this.drawingContext.fillStyle = this.color;
this.drawingContext.fillRect(x, minY, 1, 1);
this.drawingContext.fillRect(x, maxY, 1, 1);
this.colorHistory[`${x}_${minY}`] = this.color;
this.colorHistory[`${x}_${maxY}`] = this.color;
}
for (let y = minY; y <= maxY; y++) {
this.drawingContext.fillStyle = this.color;
this.drawingContext.fillRect(minX, y, 1, 1);
this.drawingContext.fillRect(maxX, y, 1, 1);
this.colorHistory[`${minX}_${y}`] = this.color;
this.colorHistory[`${maxX}_${y}`] = this.color;
}
},
floodFill(startX, startY, fillColor) {
const imageData = this.drawingContext.getImageData(0, 0, this.canvas.width, this.canvas.height);
const data = imageData.data;
const startIdx = (startY * this.canvas.width + startX) * 4;
const startR = data[startIdx];
const startG = data[startIdx + 1];
const startB = data[startIdx + 2];
const startA = data[startIdx + 3];
const fillR = parseInt(fillColor.slice(1, 3), 16);
const fillG = parseInt(fillColor.slice(3, 5), 16);
const fillB = parseInt(fillColor.slice(5, 7), 16);
const fillA = 255;
if (startR === fillR && startG === fillG && startB === fillB && startA === fillA) {
return;
}
const stack = [[startX, startY]];
const visited = new Set();
while (stack.length > 0) {
const [x, y] = stack.pop();
const key = `${x},${y}`;
if (visited.has(key)) continue;
if (x < 0 || x >= this.canvas.width || y < 0 || y >= this.canvas.height) continue;
const idx = (y * this.canvas.width + x) * 4;
const r = data[idx];
const g = data[idx + 1];
const b = data[idx + 2];
const a = data[idx + 3];
if (r !== startR || g !== startG || b !== startB || a !== startA) continue;
visited.add(key);
data[idx] = fillR;
data[idx + 1] = fillG;
data[idx + 2] = fillB;
data[idx + 3] = fillA;
stack.push([x + 1, y]);
stack.push([x - 1, y]);
stack.push([x, y + 1]);
stack.push([x, y - 1]);
}
this.drawingContext.putImageData(imageData, 0, 0);
this.rebuildColorHistory();
},
rebuildColorHistory() {
this.colorHistory = {};
const imageData = this.drawingContext.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let y = 0; y < this.canvas.height; y++) {
for (let x = 0; x < this.canvas.width; x++) {
const idx = (y * this.canvas.width + x) * 4;
const a = imageData.data[idx + 3];
if (a > 0) {
const r = imageData.data[idx];
const g = imageData.data[idx + 1];
const b = imageData.data[idx + 2];
const hexColor = this.rgbToHex(r, g, b);
this.colorHistory[`${x}_${y}`] = hexColor;
}
}
}
},
drawCircle(x1, y1, x2, y2) {
const centerX = Math.round((x1 + x2) / 2);
const centerY = Math.round((y1 + y2) / 2);
const radiusX = Math.abs(x2 - x1) / 2;
const radiusY = Math.abs(y2 - y1) / 2;
for (let angle = 0; angle < 2 * Math.PI; angle += 0.05) {
const x = Math.round(centerX + radiusX * Math.cos(angle));
const y = Math.round(centerY + radiusY * Math.sin(angle));
if (x >= 0 && x < this.canvas.width && y >= 0 && y < this.canvas.height) {
this.drawingContext.fillStyle = this.color;
this.drawingContext.fillRect(x, y, 1, 1);
this.colorHistory[`${x}_${y}`] = this.color;
}
}
},
drawLine(x1, y1, x2, y2) {
const dx = Math.abs(x2 - x1);
const dy = Math.abs(y2 - y1);
const sx = x1 < x2 ? 1 : -1;
const sy = y1 < y2 ? 1 : -1;
let err = dx - dy;
while (true) {
if (x1 >= 0 && x1 < this.canvas.width && y1 >= 0 && y1 < this.canvas.height) {
this.drawingContext.fillStyle = this.color;
this.drawingContext.fillRect(x1, y1, 1, 1);
this.colorHistory[`${x1}_${y1}`] = this.color;
}
if (x1 === x2 && y1 === y2) break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x1 += sx;
}
if (e2 < dx) {
err += dx;
y1 += sy;
}
}
},
zoomIn() {
if (this.zoomLevel < this.maxZoom) {
this.zoomLevel += this.zoomStep;
}
},
zoomOut() {
if (this.zoomLevel > this.minZoom) {
this.zoomLevel -= this.zoomStep;
}
},
resetZoom() {
this.zoomLevel = 100;
},
setPalette(theme) {
const palettes = {
classic: [
'#000000', '#FFFFFF', '#808080', '#C0C0C0', '#FF0000', '#800000',
'#FFFF00', '#808000', '#00FF00', '#008000', '#00FFFF', '#008080',
'#0000FF', '#000080', '#FF00FF', '#800080', '#FF4500', '#FFA500'
],
neon: [
'#FF00FF', '#00FFFF', '#FF0080', '#8000FF', '#00FF80', '#FF8000',
'#FF0040', '#00FF00', '#8000FF', '#FF0000', '#00FFFF', '#FF00FF',
'#40FF00', '#FF00C0', '#C0FF00', '#00C0FF', '#FF0040', '#FF8000'
],
pastel: [
'#FFB3BA', '#FFDFBA', '#FFFFBA', '#BAFFC9', '#BAE1FF', '#E1BAFF',
'#FFC9DE', '#C9DEFF', '#DEBAFF', '#BAFFEC', '#FFD9BA', '#D9BAFF',
'#BAF2FF', '#FFBAE1', '#E1FFBA', '#BAFFDA', '#DAC9FF', '#FFBAF2'
],
earth: [
'#8B4513', '#A0522D', '#CD853F', '#DEB887', '#D2691E', '#F4A460',
'#2E8B57', '#3CB371', '#556B2F', '#6B8E23', '#228B22', '#006400',
'#8B4513', '#A52A2A', '#800000', '#8B0000', '#B8860B', '#DAA520'
],
ocean: [
'#000080', '#0000CD', '#0000FF', '#1E90FF', '#4169E1', '#6495ED',
'#00CED1', '#20B2AA', '#40E0D0', '#48D1CC', '#008B8B', '#00FFFF',
'#87CEEB', '#87CEFA', '#B0E0E6', '#ADD8E6', '#4682B4', '#5F9EA0'
],
warm: [
'#FF0000', '#FF4500', '#FF6347', '#FF7F50', '#FFA500', '#FFD700',
'#FF8C00', '#FFA07A', '#FA8072', '#E9967A', '#F08080', '#CD5C5C',
'#DC143C', '#B22222', '#8B0000', '#FF1493', '#FF69B4', '#DB7093'
],
cool: [
'#0000FF', '#0000CD', '#00008B', '#191970', '#4169E1', '#6495ED',
'#1E90FF', '#00BFFF', '#00CED1', '#20B2AA', '#87CEEB', '#778899',
'#708090', '#5F9EA0', '#6A5ACD', '#7B68EE', '#9370DB', '#8A2BE2'
],
rainbow: [
'#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#9400D3',
'#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#9400D3',
'#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF'
]
};
this.colors = palettes[theme] || palettes.classic;
// Flash effect
const paletteContainer = this.$el.querySelector('.badge-drawer-palette');
if (paletteContainer) {
paletteContainer.style.transform = 'scale(1.1)';
setTimeout(() => {
paletteContainer.style.transform = 'scale(1)';
}, 200);
}
},
handleMousedown(e) {
if (e.button !== 0) return;
const canvasBoundingRect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / canvasBoundingRect.width;
const scaleY = this.canvas.height / canvasBoundingRect.height;
const x = (e.clientX - canvasBoundingRect.left) * scaleX;
const y = (e.clientY - canvasBoundingRect.top) * scaleY;
const cellX = Math.floor(x);
const cellY = Math.floor(y);
const currentColor = this.colorHistory[`${cellX}_${cellY}`];
if (this.copyMode) {
if (currentColor) {
this.color = currentColor;
this.copyMode = false;
}
} else if (this.fillMode) {
this.saveState();
this.floodFill(cellX, cellY, this.color);
this.updatePreview();
} else if (this.shapeMode) {
this.saveState();
this.shapeStart = { x: cellX, y: cellY };
this.isDrawing = true;
} else if (e.ctrlKey) {
if (currentColor) {
this.color = currentColor;
}
} else {
this.saveState();
this.paintCell(cellX, cellY);
this.isDrawing = true;
}
},
handleMousemove(e) {
if (!this.isDrawing) return;
const canvasBoundingRect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / canvasBoundingRect.width;
const scaleY = this.canvas.height / canvasBoundingRect.height;
const x = (e.clientX - canvasBoundingRect.left) * scaleX;
const y = (e.clientY - canvasBoundingRect.top) * scaleY;
const cellX = Math.floor(x);
const cellY = Math.floor(y);
if (this.shapeMode && this.shapeStart) {
// Preview handled in mouseup for simplicity
} else {
this.paintCell(cellX, cellY);
}
},
handleMouseup(e) {
if (this.shapeMode && this.shapeStart) {
const canvasBoundingRect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / canvasBoundingRect.width;
const scaleY = this.canvas.height / canvasBoundingRect.height;
const x = (e.clientX - canvasBoundingRect.left) * scaleX;
const y = (e.clientY - canvasBoundingRect.top) * scaleY;
const cellX = Math.floor(x);
const cellY = Math.floor(y);
if (this.shapeMode === 'rectangle') {
this.drawRectangle(this.shapeStart.x, this.shapeStart.y, cellX, cellY);
} else if (this.shapeMode === 'circle') {
this.drawCircle(this.shapeStart.x, this.shapeStart.y, cellX, cellY);
} else if (this.shapeMode === 'line') {
this.drawLine(this.shapeStart.x, this.shapeStart.y, cellX, cellY);
}
this.updatePreview();
this.shapeStart = null;
}
this.isDrawing = false;
},
handleTouchstart(e) {
e.preventDefault();
const touch = e.touches[0];
const canvasBoundingRect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / canvasBoundingRect.width;
const scaleY = this.canvas.height / canvasBoundingRect.height;
const x = (touch.clientX - canvasBoundingRect.left) * scaleX;
const y = (touch.clientY - canvasBoundingRect.top) * scaleY;
const cellX = Math.floor(x);
const cellY = Math.floor(y);
const currentColor = this.colorHistory[`${cellX}_${cellY}`];
if (this.copyMode) {
if (currentColor) {
this.color = currentColor;
this.copyMode = false;
}
} else {
this.paintCell(cellX, cellY);
this.isDrawing = true;
}
},
handleTouchmove(e) {
e.preventDefault();
if (!this.isDrawing) return;
const touch = e.touches[0];
const canvasBoundingRect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / canvasBoundingRect.width;
const scaleY = this.canvas.height / canvasBoundingRect.height;
const x = (touch.clientX - canvasBoundingRect.left) * scaleX;
const y = (touch.clientY - canvasBoundingRect.top) * scaleY;
const cellX = Math.floor(x);
const cellY = Math.floor(y);
this.paintCell(cellX, cellY);
},
importImage(e) {
const file = e.target.files[0];
if (!file) return;
if (!['image/png', 'image/gif'].includes(file.type)) {
alert(translations.invalid_file_type);
return;
}
if (!confirm('Import image and overwrite the canvas?')) {
e.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onerror = () => {
alert('Invalid image format. Please upload a valid PNG or GIF file.');
e.target.value = '';
};
img.onload = () => {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.cellSideCount;
tempCanvas.height = this.cellSideCount;
const tempContext = tempCanvas.getContext('2d');
tempContext.drawImage(img, 0, 0, this.cellSideCount, this.cellSideCount);
// Binarize alpha to avoid semi-transparent pixels
const imageData = tempContext.getImageData(0, 0, this.cellSideCount, this.cellSideCount);
for (let i = 0; i < imageData.data.length; i += 4) {
const a = imageData.data[i + 3];
if (a < 128) {
imageData.data[i + 3] = 0;
} else {
imageData.data[i + 3] = 255;
}
}
tempContext.putImageData(imageData, 0, 0);
// Clear the board
this.drawingContext.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.colorHistory = {};
// Draw the resized image
this.drawingContext.drawImage(tempCanvas, 0, 0);
// Update colorHistory
const importedImageData = this.drawingContext.getImageData(0, 0, this.cellSideCount, this.cellSideCount);
for (let y = 0; y < this.cellSideCount; y++) {
for (let x = 0; x < this.cellSideCount; x++) {
const index = (y * this.cellSideCount + x) * 4;
const r = importedImageData.data[index];
const g = importedImageData.data[index + 1];
const b = importedImageData.data[index + 2];
const a = importedImageData.data[index + 3];
if (a > 0) {
const hexColor = this.rgbToHex(r, g, b);
this.colorHistory[`${x}_${y}`] = hexColor;
// Add to recentColors
if (!this.recentColors.includes(hexColor)) {
this.recentColors.unshift(hexColor);
if (this.recentColors.length > 12) {
this.recentColors = this.recentColors.slice(0, 12);
}
}
}
}
}
this.updatePreview();
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
},
rgbToHex(r, g, b) {
r = Math.round(r);
g = Math.round(g);
b = Math.round(b);
return '#' + ((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1).toUpperCase();
},
paintCell(cellX, cellY) {
if (this.eraseMode) {
this.drawingContext.clearRect(cellX, cellY, 1, 1);
delete this.colorHistory[`${cellX}_${cellY}`];
} else {
this.drawingContext.fillStyle = this.color;
this.drawingContext.fillRect(cellX, cellY, 1, 1);
this.colorHistory[`${cellX}_${cellY}`] = this.color;
// Add to recentColors only when painting
if (!this.recentColors.includes(this.color)) {
this.recentColors.unshift(this.color);
if (this.recentColors.length > 12) {
this.recentColors = this.recentColors.slice(0, 12);
}
}
}
this.updatePreview();
},
clearBoard() {
if (!confirm('Clear the entire board?')) return;
this.saveState();
this.drawingContext.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.colorHistory = {};
this.recentColors = []; // Clear recent colors as well
this.updatePreview();
},
updatePreview() {
this.previewContext.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
this.previewContext.drawImage(this.canvas, 0, 0);
},
generateGifBlob() {
return new Promise((resolve) => {
// Get image data to find used colors
const imageData = this.drawingContext.getImageData(0, 0, this.canvas.width, this.canvas.height);
let usedColors = new Set();
for (let y = 0; y < this.canvas.height; y++) {
for (let x = 0; x < this.canvas.width; x++) {
const idx = (y * this.canvas.width + x) * 4;
if (imageData.data[idx + 3] > 0) { // Only consider opaque pixels
const r = imageData.data[idx];
const g = imageData.data[idx + 1];
const b = imageData.data[idx + 2];
usedColors.add((r << 16) | (g << 8) | b); // Store as integer for efficiency
}
}
}
// Find an unused color for transparency placeholder, starting from 0xFF00FF
let transColor = 0xFF00FF;
while (usedColors.has(transColor)) {
transColor = (transColor + 1) % 0x1000000; // Increment and wrap around (unlikely to loop much)
}
const transHex = '#' + transColor.toString(16).padStart(6, '0').toUpperCase();
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvas.height;
const tempContext = tempCanvas.getContext('2d');
tempContext.fillStyle = transHex; // Use the dynamic placeholder
tempContext.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
tempContext.drawImage(this.canvas, 0, 0);
const gif = new GIF({
workers: 2,
quality: 10,
workerScript: '{{ asset('js/gif/gif.worker.js') }}', // Local worker script
width: this.canvas.width,
height: this.canvas.height,
transparent: transColor // Use the dynamic integer value
});
gif.addFrame(tempCanvas);
gif.on('finished', (blob) => {
resolve(blob);
});
gif.render();
});
},
async generateCanvas(action) {
const blob = await this.generateGifBlob();
if (action === 'download') {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'badge.gif';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
},
async buyBadge() {
if (Object.keys(this.colorHistory).length === 0 || !this.badgeName.trim() || !this.badgeDescription.trim()) {
alert(translations.missing_fields);
return;
}
if (this.badgeName.match(/https?:\/\/|www\./i) || this.badgeDescription.match(/https?:\/\/|www\./i)) {
alert(translations.invalid_content);
return;
}
if (!this.canBuy || !confirm(translations.buy_confirmation)) return;
this.lastBuyTime = Date.now();
this.canBuy = false;
this.buttonText = `Cooldown (${Math.ceil((30000 - (Date.now() - this.lastBuyTime)) / 1000)} sec)`;
const interval = setInterval(() => {
const remainingTime = Math.ceil((30000 - (Date.now() - this.lastBuyTime)) / 1000);
if (remainingTime <= 0) {
clearInterval(interval);
this.canBuy = true;
this.buttonText = `{{ __('Buy Badge') }} (${this.cost} ${this.currencyType.charAt(0).toUpperCase() + this.currencyType.slice(1)})`;
} else {
this.buttonText = `Cooldown (${remainingTime} sec)`;
}
}, 1000);
const blob = await this.generateGifBlob();
const reader = new FileReader();
reader.onloadend = () => {
const base64data = reader.result;
fetch('{{ route('badge.buy') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ badge_data: base64data, badge_name: this.badgeName, badge_description: this.badgeDescription })
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(translations.purchase_success);
} else {
alert(data.message || translations.purchase_error_insufficient);
clearInterval(interval); // Clear interval if buy fails
this.canBuy = true;
this.buttonText = `{{ __('Buy Badge') }} (${this.cost} ${this.currencyType.charAt(0).toUpperCase() + this.currencyType.slice(1)})`;
}
})
.catch(error => {
console.error('Error:', error);
alert(translations.purchase_error_general);
clearInterval(interval); // Clear interval if buy fails
this.canBuy = true;
this.buttonText = `{{ __('Buy Badge') }} (${this.cost} ${this.currencyType.charAt(0).toUpperCase() + this.currencyType.slice(1)})`;
});
};
reader.readAsDataURL(blob);
// Ensure cooldown ends even if fetch fails or takes long
setTimeout(() => {
if (!this.canBuy) {
clearInterval(interval);
this.canBuy = true;
this.buttonText = `{{ __('Buy Badge') }} (${this.cost} ${this.currencyType.charAt(0).toUpperCase() + this.currencyType.slice(1)})`;
}
}, 30000);
}
}
}
</script>
<style>
#canvas {
cursor: pointer;
}
#guide {
display: block;
pointer-events: none;
position: absolute;
top: 0;
left: 0;
background-repeat: repeat;
}
.checkerboard {
}
.dark .checkerboard {
}
input[type="color"] {
position: absolute;
top: auto;
left: auto;
bottom: 0;
right: 0;
transform: translateY(100%);
}
#avatarbox {
background: url('/assets/images/badgecreator/avatarbox.png');
width: 199px;
height: 180px;
float: right;
}
.tint-red {
filter: invert(21%) sepia(87%) saturate(4855%) hue-rotate(346deg) brightness(91%) contrast(92%);
}
</style>
</x-app-layout>