You've already forked Atomcms-edit
c53a5a8a2c
- Converted all views from Dusk components (x-content.content-card, x-form.*) to inline Hubbly style - All pages use consistent card pattern: rounded-lg, gradient headers, color-mix borders - Added Hubbly-style homepage with 2-column layout, login card, swiper news carousel - Rewrote navigation with Alpine.js dropdowns, CSS variable colors, Hubbly assets - Updated profile page with Hubbly cards, fixed data bugs (friend/guild relationships) - Rewrote register page to match Hubbly layout: banner header, avatar preview with Frank GIF, 2-column form, avatar carousel selector, border-4 inputs - Rewrote login, settings, help center, radio, applications, utility pages - All colors use CSS variables controlled by Filament theme editor - Added Hubbly assets: banner, Frank GIF, navigation icons, online badge - Removed all dependencies on x-content.* and x-form.* components
885 lines
49 KiB
PHP
Executable File
885 lines
49 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 }}' })">
|
|
|
|
<div class="col-span-3">
|
|
<div class="rounded-lg overflow-hidden" style="background-color: var(--color-surface); border: 1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent);">
|
|
<div class="relative w-full" style="background: linear-gradient(140deg, var(--color-primary) 0%, color-mix(in srgb, var(--color-primary) 80%, black) 100%);">
|
|
<div class="flex items-center h-full px-4 py-3 gap-3">
|
|
<div class="w-8 h-8 rounded-full flex items-center justify-center text-lg shadow-lg" style="background-color: color-mix(in srgb, var(--color-primary) 30%, transparent);">
|
|
🏨
|
|
</div>
|
|
<div>
|
|
<p class="text-white font-bold text-sm">{{ __('Badge Drawer') }}</p>
|
|
<p class="text-xs" style="color: rgba(255,255,255,0.8)">{{ __('Draw your very own badge') }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="p-4 text-sm" style="color: var(--color-text)">
|
|
@if ($folderError)
|
|
<div class="px-4 py-3 rounded mb-4" style="background-color: color-mix(in srgb, #ef4444 15%, transparent); border: 1px solid color-mix(in srgb, #ef4444 30%, transparent); color: #ef4444;" role="alert">
|
|
<strong class="font-bold">Error:</strong>
|
|
<span class="block sm:inline">{{ $errorMessage }}</span>
|
|
</div>
|
|
@endif
|
|
<div class="flex flex-col gap-6">
|
|
<div class="p-4 space-y-4 rounded" style="background-color: color-mix(in srgb, var(--color-background) 50%, transparent);">
|
|
<div>
|
|
<p class="text-xs font-semibold uppercase tracking-wider mb-2" style="color: var(--color-text-muted)">{{ __('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 ? 'text-white shadow-lg scale-105' : 'hover:border-[var(--color-primary)]', effectsEnabled ? 'btn-sparkle btn-float' : '']"
|
|
:style="copyMode ? 'background-color: var(--color-primary); border-color: var(--color-primary);' : 'border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text); background-color: var(--color-background);'">
|
|
<i class="fa-solid fa-eye-dropper text-lg"></i>
|
|
<span class="text-[10px] font-medium">{{ __('Copy') }}</span>
|
|
</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 ? 'text-white shadow-lg scale-105' : 'hover:border-[var(--color-primary)]', effectsEnabled ? 'btn-sparkle btn-float' : '']"
|
|
:style="eraseMode ? 'background-color: var(--color-primary); border-color: var(--color-primary);' : 'border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text); background-color: var(--color-background);'">
|
|
<i class="fa-solid fa-eraser text-lg"></i>
|
|
<span class="text-[10px] font-medium">{{ __('Erase') }}</span>
|
|
</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 ? 'text-white shadow-lg scale-105' : 'hover:border-[var(--color-primary)]', effectsEnabled ? 'btn-sparkle btn-float' : '']"
|
|
:style="fillMode ? 'background-color: var(--color-primary); border-color: var(--color-primary);' : 'border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text); background-color: var(--color-background);'">
|
|
<i class="fa-solid fa-fill text-lg"></i>
|
|
<span class="text-[10px] font-medium">{{ __('Fill') }}</span>
|
|
</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' ? 'text-white shadow-lg scale-105' : 'hover:border-[var(--color-primary)]', effectsEnabled ? 'btn-sparkle btn-float' : '']"
|
|
:style="shapeMode === 'rectangle' ? 'background-color: var(--color-primary); border-color: var(--color-primary);' : 'border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text); background-color: var(--color-background);'">
|
|
<i class="fa-regular fa-square text-lg"></i>
|
|
<span class="text-[10px] font-medium">{{ __('Rect') }}</span>
|
|
</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' ? 'text-white shadow-lg scale-105' : 'hover:border-[var(--color-primary)]', effectsEnabled ? 'btn-sparkle btn-float' : '']"
|
|
:style="shapeMode === 'circle' ? 'background-color: var(--color-primary); border-color: var(--color-primary);' : 'border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text); background-color: var(--color-background);'">
|
|
<i class="fa-regular fa-circle text-lg"></i>
|
|
<span class="text-[10px] font-medium">{{ __('Circle') }}</span>
|
|
</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' ? 'text-white shadow-lg scale-105' : 'hover:border-[var(--color-primary)]', effectsEnabled ? 'btn-sparkle btn-float' : '']"
|
|
:style="shapeMode === 'line' ? 'background-color: var(--color-primary); border-color: var(--color-primary);' : 'border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text); background-color: var(--color-background);'">
|
|
<i class="fa-solid fa-minus text-lg"></i>
|
|
<span class="text-[10px] font-medium">{{ __('Line') }}</span>
|
|
</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 ? 'text-white shadow-lg scale-105' : 'hover:border-[var(--color-primary)]', effectsEnabled ? 'btn-sparkle btn-float' : '']"
|
|
:style="textMode ? 'background-color: var(--color-primary); border-color: var(--color-primary);' : 'border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text); background-color: var(--color-background);'">
|
|
<i class="fa-solid fa-font text-lg"></i>
|
|
<span class="text-[10px] font-medium">{{ __('Text') }}</span>
|
|
</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"
|
|
:style="effectsEnabled ? 'background-color: #a855f7; border-color: #a855f7; color: white; box-shadow: 0 10px 15px -3px rgba(168,85,247,0.3); transform: scale(1.05);' : 'border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text); background-color: var(--color-background);'">
|
|
<i class="fa-solid fa-wand-magic-sparkles text-lg"></i>
|
|
<span class="text-[10px] font-medium">{{ __('FX') }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<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="p-4 rounded-xl shadow-lg space-y-3" style="background-color: var(--color-surface); border: 2px solid var(--color-primary);"
|
|
@click.away="textModalOpen = false">
|
|
<p class="text-sm font-semibold" style="color: var(--color-text)">{{ __('Add Text') }}</p>
|
|
<input type="text" x-model="textInput" placeholder="{{ __('Enter text') }}" maxlength="3"
|
|
class="w-full rounded-lg border px-3 py-2 text-sm focus:outline-none"
|
|
style="background-color: var(--color-background); border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text);"
|
|
onfocus="this.style.borderColor='var(--color-primary)'"
|
|
onblur="this.style.borderColor='color-mix(in srgb, var(--color-text-muted) 20%, transparent)'">
|
|
<div class="flex gap-2">
|
|
<select x-model="textSize" class="px-2 py-1 rounded border text-sm" style="background-color: var(--color-background); border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text);">
|
|
<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 text-sm font-medium transition-all" style="background-color: var(--color-primary); color: var(--button-text-color);">
|
|
{{ __('Add') }}
|
|
</button>
|
|
<button @click="textModalOpen = false; textMode = false"
|
|
class="px-3 py-1.5 rounded-lg text-sm transition-all" style="border: 1px solid color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text);">
|
|
{{ __('Cancel') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center gap-4">
|
|
<div class="flex-1 min-w-[200px]">
|
|
<p class="text-xs font-semibold uppercase tracking-wider mb-2" style="color: var(--color-text-muted)">{{ __('View') }}</p>
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex items-center gap-1 rounded-xl p-1.5 shadow-sm" style="background-color: var(--color-background); border: 1px solid color-mix(in srgb, var(--color-text-muted) 20%, transparent);">
|
|
<button @click="zoomOut" class="w-10 h-10 flex items-center justify-center rounded-lg transition-all hover:scale-110" style="color: var(--color-text-muted);" 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" style="color: var(--color-text)" x-text="zoomLevel + '%'"></span>
|
|
<button @click="zoomIn" class="w-10 h-10 flex items-center justify-center rounded-lg transition-all hover:scale-110" style="color: var(--color-text-muted);" 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"
|
|
:style="showGrid ? 'background-color: var(--color-primary); border-color: var(--color-primary); color: var(--button-text-color); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);' : 'border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text); background-color: var(--color-background);'">
|
|
<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 uppercase tracking-wider mb-2" style="color: var(--color-text-muted)">{{ __('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"
|
|
:style="undoStack.length === 0 ? 'border-color: color-mix(in srgb, var(--color-text-muted) 10%, transparent); color: color-mix(in srgb, var(--color-text-muted) 40%, transparent); cursor: not-allowed;' : 'border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text); background-color: var(--color-background);'">
|
|
<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"
|
|
:style="redoStack.length === 0 ? 'border-color: color-mix(in srgb, var(--color-text-muted) 10%, transparent); color: color-mix(in srgb, var(--color-text-muted) 40%, transparent); cursor: not-allowed;' : 'border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text); background-color: var(--color-background);'">
|
|
<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 transition-all duration-200 text-sm font-medium" style="border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text); background-color: var(--color-background);">
|
|
<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" style="image-rendering: pixelated; background: transparent; border: 1px solid color-mix(in srgb, var(--color-text-muted) 20%, transparent);" role="img" aria-label="{{ __('Badge drawing area') }}"></canvas>
|
|
</div>
|
|
</div>
|
|
<div class="p-4 space-y-4 rounded" style="background-color: color-mix(in srgb, var(--color-background) 50%, transparent);">
|
|
<div>
|
|
<p class="text-xs font-semibold uppercase tracking-wider mb-2" style="color: var(--color-text-muted)">{{ __('Current Color') }}</p>
|
|
<div class="flex items-center gap-3">
|
|
<button @click="$refs.colorInput.click()" class="w-14 h-14 rounded-xl shadow-inner flex items-center justify-center relative overflow-hidden transition-all hover:scale-105" :style="'background-color: ' + color + '; border: 2px solid color-mix(in srgb, var(--color-text-muted) 20%, transparent);'">
|
|
<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" style="color: var(--color-text)">{{ __('Click to change color') }}</p>
|
|
<p class="font-mono" style="color: var(--color-text-muted)" x-text="color.toUpperCase()"></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p class="text-xs font-semibold uppercase tracking-wider mb-2" style="color: var(--color-text-muted)">{{ __('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>
|
|
|
|
<div>
|
|
<p class="text-xs font-semibold uppercase tracking-wider mb-2" style="color: var(--color-text-muted)">{{ __('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" style="border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent);"></div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p class="text-xs font-semibold uppercase tracking-wider mb-2" style="color: var(--color-text-muted)">{{ __('Color Themes') }}</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
<button @click="setPalette('classic')" class="px-3 py-2 text-xs rounded-lg border transition-all duration-200 font-medium" style="border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text);">{{ __('Classic') }}</button>
|
|
<button @click="setPalette('neon')" class="px-3 py-2 text-xs rounded-lg border transition-all duration-200 font-medium" style="border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text);">{{ __('Neon') }}</button>
|
|
<button @click="setPalette('pastel')" class="px-3 py-2 text-xs rounded-lg border transition-all duration-200 font-medium" style="border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text);">{{ __('Pastel') }}</button>
|
|
<button @click="setPalette('earth')" class="px-3 py-2 text-xs rounded-lg border transition-all duration-200 font-medium" style="border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text);">{{ __('Earth') }}</button>
|
|
<button @click="setPalette('ocean')" class="px-3 py-2 text-xs rounded-lg border transition-all duration-200 font-medium" style="border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text);">{{ __('Ocean') }}</button>
|
|
<button @click="setPalette('warm')" class="px-3 py-2 text-xs rounded-lg border transition-all duration-200 font-medium" style="border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text);">{{ __('Warm') }}</button>
|
|
<button @click="setPalette('cool')" class="px-3 py-2 text-xs rounded-lg border transition-all duration-200 font-medium" style="border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text);">{{ __('Cool') }}</button>
|
|
<button @click="setPalette('rainbow')" class="px-3 py-2 text-xs rounded-lg border transition-all duration-200 font-medium" style="border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text);">{{ __('Rainbow') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-5 space-y-4 rounded" style="background-color: var(--color-background); border: 1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent);">
|
|
<div class="flex items-center gap-2">
|
|
<i class="fa-solid fa-bolt" style="color: var(--color-primary)"></i>
|
|
<p class="text-sm font-bold uppercase tracking-wide" style="color: var(--color-text-muted)">{{ __('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 border-2 transition-all duration-200" style="background-color: color-mix(in srgb, #ef4444 10%, transparent); border-color: color-mix(in srgb, #ef4444 30%, transparent);">
|
|
<i class="fa-solid fa-trash-can text-2xl" style="color: #ef4444; group-hover: scale-110; transition-transform: transform;"></i>
|
|
<span class="text-sm font-bold" style="color: #ef4444;">{{ __('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 border-2 transition-all duration-200" style="background-color: color-mix(in srgb, var(--color-primary) 10%, transparent); border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);">
|
|
<i class="fa-solid fa-cloud-arrow-down text-2xl" style="color: var(--color-primary);"></i>
|
|
<span class="text-sm font-bold" style="color: var(--color-primary);">{{ __('Download Badge') }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-span-1 h-max">
|
|
<div class="rounded-lg overflow-hidden" style="background-color: var(--color-surface); border: 1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent);">
|
|
<div class="relative w-full" style="background: linear-gradient(140deg, var(--color-primary) 0%, color-mix(in srgb, var(--color-primary) 80%, black) 100%);">
|
|
<div class="flex items-center h-full px-4 py-3 gap-3">
|
|
<div class="w-8 h-8 rounded-full flex items-center justify-center text-lg shadow-lg" style="background-color: color-mix(in srgb, var(--color-primary) 30%, transparent);">
|
|
🏨
|
|
</div>
|
|
<div>
|
|
<p class="text-white font-bold text-sm">{{ __('Badge Drawer Details') }}</p>
|
|
<p class="text-xs" style="color: rgba(255,255,255,0.8)">{{ __('My Badge Details') }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-4 text-sm" style="color: var(--color-text)">
|
|
<div class="flex flex-col gap-3">
|
|
<h3 class="font-semibold" style="color: var(--color-text)">{{ __('Preview') }}</h3>
|
|
<div id="avatarbox" class="mx-auto" style="background: url('/assets/images/badgecreator/avatarbox.png'); width: 199px; height: 180px;">
|
|
<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" style="color: var(--color-text)">{{ __('Badge Name') }}</label>
|
|
<input type="text" id="badgeName" x-model="badgeName" maxlength="24" placeholder="{{ __('Enter badge name') }}"
|
|
class="w-full rounded-lg border px-3 py-2 text-sm focus:outline-none transition-all duration-200"
|
|
style="background-color: var(--color-background); border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text);"
|
|
onfocus="this.style.borderColor='var(--color-primary)'"
|
|
onblur="this.style.borderColor='color-mix(in srgb, var(--color-text-muted) 20%, transparent)'">
|
|
<p class="text-xs mt-1" style="color: var(--color-text-muted)" x-text="badgeName.length + '/24'"></p>
|
|
</div>
|
|
<div>
|
|
<label for="badgeDescription" class="block text-sm font-semibold mb-1.5" style="color: var(--color-text)">{{ __('Description') }}</label>
|
|
<input type="text" id="badgeDescription" x-model="badgeDescription" maxlength="255" placeholder="{{ __('Enter description') }}"
|
|
class="w-full rounded-lg border px-3 py-2 text-sm focus:outline-none transition-all duration-200"
|
|
style="background-color: var(--color-background); border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); color: var(--color-text);"
|
|
onfocus="this.style.borderColor='var(--color-primary)'"
|
|
onblur="this.style.borderColor='color-mix(in srgb, var(--color-text-muted) 20%, transparent)'">
|
|
<p class="text-xs mt-1" style="color: var(--color-text-muted)" 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"
|
|
:style="isValid && !{{ $folderError ? 'true' : 'false' }} ? 'background-color: #22c55e; color: white; border: 2px solid #16a34a;' : 'background-color: color-mix(in srgb, var(--color-text-muted) 15%, transparent); color: color-mix(in srgb, var(--color-text-muted) 50%, transparent); cursor: not-allowed; border: 2px solid color-mix(in srgb, var(--color-text-muted) 10%, transparent);'"
|
|
: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-center" style="color: #ef4444;" x-cloak>
|
|
{{ __('Please fill in all fields and draw something') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<link rel="stylesheet" href="{{ config('habbo.cdn.fontawesome_css') }}" 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; }
|
|
#canvas { cursor: pointer; }
|
|
#guide { display: block; pointer-events: none; position: absolute; top: 0; left: 0; background-repeat: repeat; }
|
|
input[type="color"] { position: absolute; top: auto; left: auto; bottom: 0; right: 0; transform: translateY(100%); }
|
|
#avatarbox { float: right; }
|
|
.tint-red { filter: invert(21%) sepia(87%) saturate(4855%) hue-rotate(346deg) brightness(91%) contrast(92%); }
|
|
</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');
|
|
|
|
const canvasWidth = this.canvas.clientWidth;
|
|
const cellSize = canvasWidth / this.cellSideCount;
|
|
|
|
this.guide.style.width = `${canvasWidth}px`;
|
|
this.guide.style.height = `${canvasWidth}px`;
|
|
this.guide.innerHTML = '';
|
|
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
const borderColor = isDark ? '#fff' : '#4b5563';
|
|
|
|
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)
|
|
`;
|
|
|
|
this.$watch('showGrid', (value) => {
|
|
this.guide.style.display = value ? 'block' : 'none';
|
|
});
|
|
|
|
const checkerboardEl = this.$el.querySelector('.checkerboard');
|
|
let bgColor, checkColor;
|
|
if (isDark) {
|
|
bgColor = '#1a1a1a';
|
|
checkColor = '#2a2a2a';
|
|
} else {
|
|
bgColor = '#fff';
|
|
checkColor = '#eee';
|
|
}
|
|
const checkUnit = cellSize / 2;
|
|
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`;
|
|
|
|
this.$watch('eraseMode', (value) => {
|
|
if (value) this.copyMode = false;
|
|
});
|
|
this.$watch('copyMode', (value) => {
|
|
if (value) this.eraseMode = false;
|
|
});
|
|
|
|
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; });
|
|
|
|
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; });
|
|
|
|
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();
|
|
}
|
|
});
|
|
|
|
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();
|
|
const centerX = Math.floor(this.canvas.width / 2);
|
|
const centerY = Math.floor(this.canvas.height / 2);
|
|
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';
|
|
this.drawingContext.fillText(this.textInput.toUpperCase(), centerX, centerY);
|
|
this.rebuildColorHistory();
|
|
this.updatePreview();
|
|
this.textInput = '';
|
|
this.textModalOpen = false;
|
|
this.textMode = false;
|
|
},
|
|
|
|
saveState() {
|
|
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)) });
|
|
if (this.undoStack.length > this.maxUndoSize) { this.undoStack.shift(); }
|
|
this.redoStack = [];
|
|
},
|
|
|
|
undo() {
|
|
if (this.undoStack.length === 0) return;
|
|
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)) });
|
|
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;
|
|
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)) });
|
|
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), maxX = Math.max(x1, x2);
|
|
const minY = Math.min(y1, y2), 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], startG = data[startIdx+1], startB = data[startIdx+2], startA = data[startIdx+3];
|
|
const fillR = parseInt(fillColor.slice(1,3),16), fillG = parseInt(fillColor.slice(3,5),16), fillB = parseInt(fillColor.slice(5,7),16);
|
|
if (startR===fillR && startG===fillG && startB===fillB) return;
|
|
const stack = [[startX, startY]], 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;
|
|
if (data[idx]!==startR||data[idx+1]!==startG||data[idx+2]!==startB||data[idx+3]!==startA) continue;
|
|
visited.add(key);
|
|
data[idx]=fillR; data[idx+1]=fillG; data[idx+2]=fillB; data[idx+3]=255;
|
|
stack.push([x+1,y],[x-1,y],[x,y+1],[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;
|
|
if (imageData.data[idx+3] > 0) {
|
|
this.colorHistory[`${x}_${y}`] = this.rgbToHex(imageData.data[idx], imageData.data[idx+1], imageData.data[idx+2]);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
drawCircle(x1, y1, x2, y2) {
|
|
const centerX = Math.round((x1+x2)/2), centerY = Math.round((y1+y2)/2);
|
|
const radiusX = Math.abs(x2-x1)/2, 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) {
|
|
let dx = Math.abs(x2-x1), dy = Math.abs(y2-y1);
|
|
let sx = x1<x2?1:-1, 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;
|
|
},
|
|
|
|
handleMousedown(e) {
|
|
if (e.button !== 0) return;
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const scaleX = this.canvas.width / rect.width, scaleY = this.canvas.height / rect.height;
|
|
const x = (e.clientX - rect.left) * scaleX, y = (e.clientY - rect.top) * scaleY;
|
|
const cellX = Math.floor(x), 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 rect = this.canvas.getBoundingClientRect();
|
|
const scaleX = this.canvas.width / rect.width, scaleY = this.canvas.height / rect.height;
|
|
const x = (e.clientX - rect.left) * scaleX, y = (e.clientY - rect.top) * scaleY;
|
|
if (!this.shapeMode) this.paintCell(Math.floor(x), Math.floor(y));
|
|
},
|
|
|
|
handleMouseup(e) {
|
|
if (this.shapeMode && this.shapeStart) {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const scaleX = this.canvas.width / rect.width, scaleY = this.canvas.height / rect.height;
|
|
const cellX = Math.floor((e.clientX - rect.left) * scaleX), cellY = Math.floor((e.clientY - rect.top) * scaleY);
|
|
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 rect = this.canvas.getBoundingClientRect();
|
|
const scaleX = this.canvas.width / rect.width, scaleY = this.canvas.height / rect.height;
|
|
const cellX = Math.floor((touch.clientX - rect.left) * scaleX), cellY = Math.floor((touch.clientY - rect.top) * scaleY);
|
|
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 rect = this.canvas.getBoundingClientRect();
|
|
const scaleX = this.canvas.width / rect.width, scaleY = this.canvas.height / rect.height;
|
|
this.paintCell(Math.floor((touch.clientX - rect.left) * scaleX), Math.floor((touch.clientY - rect.top) * scaleY));
|
|
},
|
|
|
|
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.'); 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);
|
|
const imageData = tempContext.getImageData(0, 0, this.cellSideCount, this.cellSideCount);
|
|
for (let i = 0; i < imageData.data.length; i += 4) {
|
|
if (imageData.data[i+3] < 128) imageData.data[i+3] = 0; else imageData.data[i+3] = 255;
|
|
}
|
|
tempContext.putImageData(imageData, 0, 0);
|
|
this.drawingContext.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
this.colorHistory = {};
|
|
this.drawingContext.drawImage(tempCanvas, 0, 0);
|
|
const importedData = 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 idx = (y*this.cellSideCount+x)*4;
|
|
if (importedData.data[idx+3] > 0) {
|
|
const hexColor = this.rgbToHex(importedData.data[idx], importedData.data[idx+1], importedData.data[idx+2]);
|
|
this.colorHistory[`${x}_${y}`] = hexColor;
|
|
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) { 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;
|
|
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 = [];
|
|
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) => {
|
|
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) { usedColors.add((imageData.data[idx]<<16)|(imageData.data[idx+1]<<8)|imageData.data[idx+2]); }
|
|
}
|
|
}
|
|
let transColor = 0xFF00FF;
|
|
while (usedColors.has(transColor)) transColor = (transColor+1)%0x1000000;
|
|
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;
|
|
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') }}', width:this.canvas.width, height:this.canvas.height, transparent:transColor });
|
|
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); 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); this.canBuy = true; this.buttonText = `{{ __('Buy Badge') }} (${this.cost} ${this.currencyType.charAt(0).toUpperCase() + this.currencyType.slice(1)})`; });
|
|
};
|
|
reader.readAsDataURL(blob);
|
|
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>
|
|
</x-app-layout>
|