You've already forked Atomcms-edit
1342 lines
50 KiB
PHP
Executable File
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> |