Auth: convert auth views and verification email to Nova layout

This commit is contained in:
2026-02-21 07:37:08 +01:00
parent 93b009d42a
commit 795c7a835f
117 changed files with 5385 additions and 1291 deletions

View File

@@ -0,0 +1,6 @@
import React from 'react'
import AdminUsernameQueue from '../../components/admin/AdminUsernameQueue'
export default function UsernameQueuePage() {
return <AdminUsernameQueue />
}

View File

@@ -1,4 +1,5 @@
import './bootstrap';
import './username-availability';
import Alpine from 'alpinejs';
import React from 'react';

View File

@@ -0,0 +1,92 @@
import React, { useEffect, useState } from 'react'
export default function AdminUsernameQueue() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [notes, setNotes] = useState({})
const loadPending = async () => {
setLoading(true)
setError('')
try {
const response = await window.axios.get('/api/admin/usernames/pending')
setItems(Array.isArray(response?.data?.data) ? response.data.data : [])
} catch (loadError) {
setError(loadError?.response?.data?.message || 'Failed to load username moderation queue.')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadPending()
}, [])
const moderate = async (id, action) => {
try {
const payload = { note: String(notes[id] || '') }
await window.axios.post(`/api/admin/usernames/${id}/${action}`, payload)
setItems((prev) => prev.filter((item) => item.id !== id))
} catch (moderateError) {
setError(moderateError?.response?.data?.message || `Failed to ${action} username request.`)
}
}
return (
<section aria-label="Username moderation queue" className="mx-auto w-full max-w-5xl rounded-2xl border border-white/10 bg-slate-900/60 p-4 md:p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Pending Username Approvals</h2>
<button type="button" onClick={loadPending} className="rounded-lg border border-white/20 px-3 py-1 text-xs text-white">
Refresh
</button>
</div>
{loading ? <p role="status" className="text-sm text-white/70">Loading</p> : null}
{error ? <p role="alert" className="mb-3 text-sm text-rose-200">{error}</p> : null}
{!loading && items.length === 0 ? <p role="status" className="text-sm text-white/60">No pending username requests.</p> : null}
<ul className="space-y-3">
{items.map((item) => (
<li key={item.id} className="rounded-xl border border-white/10 bg-white/5 p-3">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-sm font-medium text-white">{item.requested_username}</div>
<div className="mt-1 text-xs text-white/65">Request #{item.id} · {item.context}</div>
{item.similar_to ? <div className="mt-1 text-xs text-amber-200">Similar to reserved: {item.similar_to}</div> : null}
</div>
<div className="w-full max-w-sm space-y-2">
<input
type="text"
aria-label={`Moderation note for request ${item.id}`}
value={notes[item.id] || ''}
onChange={(event) => setNotes((prev) => ({ ...prev, [item.id]: event.target.value }))}
placeholder="Review note"
className="w-full rounded-lg border border-white/15 bg-white/10 px-3 py-2 text-xs text-white"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => moderate(item.id, 'approve')}
className="rounded-lg bg-emerald-500 px-3 py-2 text-xs font-semibold text-black"
>
Approve
</button>
<button
type="button"
onClick={() => moderate(item.id, 'reject')}
className="rounded-lg bg-rose-500 px-3 py-2 text-xs font-semibold text-white"
>
Reject
</button>
</div>
</div>
</div>
</li>
))}
</ul>
</section>
)
}

View File

@@ -0,0 +1,62 @@
const debounce = (fn, wait = 350) => {
let timeoutId
return (...args) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn(...args), wait)
}
}
const setStatus = (target, message, tone = 'neutral') => {
if (!target) return
target.textContent = message || ''
target.classList.remove('text-green-600', 'text-red-600', 'text-gray-500')
if (tone === 'success') target.classList.add('text-green-600')
else if (tone === 'error') target.classList.add('text-red-600')
else target.classList.add('text-gray-500')
}
const initUsernameAvailability = () => {
const fields = document.querySelectorAll('[data-username-field="true"]')
fields.forEach((field) => {
const url = field.getAttribute('data-availability-url') || '/api/username/availability'
const statusId = field.getAttribute('data-availability-target')
const statusEl = statusId ? document.getElementById(statusId) : null
const check = debounce(async () => {
const raw = String(field.value || '')
const username = raw.trim().toLowerCase()
if (!username) {
setStatus(statusEl, '')
return
}
setStatus(statusEl, 'Checking availability...')
try {
const response = await window.axios.get(url, { params: { username } })
const data = response?.data || {}
if (data.available) {
setStatus(statusEl, `Available: ${data.normalized || username}`, 'success')
} else {
setStatus(statusEl, `Taken: ${data.normalized || username}`, 'error')
}
} catch (error) {
if (error?.response?.status === 422) {
const message = error?.response?.data?.errors?.username?.[0] || 'Invalid username.'
setStatus(statusEl, message, 'error')
return
}
setStatus(statusEl, 'Could not check availability right now.', 'error')
}
})
field.addEventListener('input', check)
})
}
document.addEventListener('DOMContentLoaded', initUsernameAvailability)

View File

@@ -54,7 +54,7 @@
</ul>
<div class="mt-6 font-semibold text-white/80 mb-2">Browse Subcategories:</div>
<ul class="space-y-2 sb-scrollbar max-h-56 overflow-auto pr-2">
<ul class="space-y-2 pr-2">
@foreach($subcategories as $sub)
<li><a class="hover:text-white {{ $category && $sub->id === $category->id ? 'font-semibold text-white' : 'text-neutral-400' }}" href="{{ $sub->url }}">{{ $sub->name }}</a></li>
@endforeach

View File

@@ -1,27 +1,31 @@
<x-guest-layout>
<div class="mb-4 text-sm text-gray-600">
{{ __('This is a secure area of the application. Please confirm your password before continuing.') }}
@extends('layouts.nova')
@section('content')
<div class="px-4 py-8 md:px-6 md:py-10">
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
<h1 class="text-2xl font-semibold text-white">Confirm Password</h1>
<p class="mt-2 text-sm text-sb-muted">Please confirm your password before continuing.</p>
<form method="POST" action="{{ route('password.confirm') }}" class="mt-4">
@csrf
<div>
<x-input-label for="password" :value="__('Password')" class="text-sb-muted" />
<x-text-input id="password" class="block mt-1 w-full bg-black/20 border-sb-line text-white"
type="password"
name="password"
required autocomplete="current-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<div class="flex justify-end mt-4">
<x-primary-button>
{{ __('Confirm') }}
</x-primary-button>
</div>
</form>
</div>
<form method="POST" action="{{ route('password.confirm') }}">
@csrf
<!-- Password -->
<div>
<x-input-label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="current-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<div class="flex justify-end mt-4">
<x-primary-button>
{{ __('Confirm') }}
</x-primary-button>
</div>
</form>
</x-guest-layout>
</div>
@endsection

View File

@@ -1,25 +1,28 @@
<x-guest-layout>
<div class="mb-4 text-sm text-gray-600">
{{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }}
@extends('layouts.nova')
@section('content')
<div class="px-4 py-8 md:px-6 md:py-10">
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
<h1 class="text-2xl font-semibold text-white">Reset Password</h1>
<p class="mt-2 text-sm text-sb-muted">Enter your email and we'll send a link to reset your password.</p>
<x-auth-session-status class="mt-4 mb-2 text-green-300" :status="session('status')" />
<form method="POST" action="{{ route('password.email') }}" class="mt-4">
@csrf
<div>
<x-input-label for="email" :value="__('Email')" class="text-sb-muted" />
<x-text-input id="email" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="email" name="email" :value="old('email')" required autofocus />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button>
{{ __('Email Password Reset Link') }}
</x-primary-button>
</div>
</form>
</div>
<!-- Session Status -->
<x-auth-session-status class="mb-4" :status="session('status')" />
<form method="POST" action="{{ route('password.email') }}">
@csrf
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button>
{{ __('Email Password Reset Link') }}
</x-primary-button>
</div>
</form>
</x-guest-layout>
</div>
@endsection

View File

@@ -1,47 +1,49 @@
<x-guest-layout>
<!-- Session Status -->
<x-auth-session-status class="mb-4" :status="session('status')" />
@extends('layouts.nova')
<form method="POST" action="{{ route('login') }}">
@csrf
@section('content')
<div class="px-4 py-8 md:px-6 md:py-10">
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
<h1 class="text-2xl font-semibold text-white">Log in</h1>
<p class="mt-2 text-sm text-sb-muted">Sign in to continue to your Skinbase account.</p>
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<x-auth-session-status class="mt-4 mb-2 text-green-300" :status="session('status')" />
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<form method="POST" action="{{ route('login') }}" class="mt-4 space-y-4">
@csrf
<x-text-input id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="current-password" />
<div>
<x-input-label for="email" :value="__('Email')" class="text-sb-muted" />
<x-text-input id="email" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<div>
<x-input-label for="password" :value="__('Password')" class="text-sb-muted" />
<x-text-input id="password" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="password" name="password" required autocomplete="current-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Remember Me -->
<div class="block mt-4">
<label for="remember_me" class="inline-flex items-center">
<input id="remember_me" type="checkbox" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500" name="remember">
<span class="ms-2 text-sm text-gray-600">{{ __('Remember me') }}</span>
</label>
</div>
<div class="block mt-1">
<label for="remember_me" class="inline-flex items-center">
<input id="remember_me" type="checkbox" class="rounded border-sb-line bg-black/20 text-sb-blue shadow-sm focus:ring-sb-blue" name="remember">
<span class="ms-2 text-sm text-sb-muted">{{ __('Remember me') }}</span>
</label>
</div>
<div class="flex items-center justify-end mt-4">
@if (Route::has('password.request'))
<a class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" href="{{ route('password.request') }}">
{{ __('Forgot your password?') }}
</a>
@endif
<div class="flex items-center justify-between pt-2">
@if (Route::has('password.request'))
<a class="underline text-sm text-sb-muted hover:text-white" href="{{ route('password.request') }}">
{{ __('Forgot your password?') }}
</a>
@else
<span></span>
@endif
<x-primary-button class="ms-3">
{{ __('Log in') }}
</x-primary-button>
</div>
</form>
</x-guest-layout>
<x-primary-button class="justify-center">
{{ __('Log in') }}
</x-primary-button>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -0,0 +1,27 @@
@php
$steps = [
'email' => 'Email',
'verified' => 'Verified',
'password' => 'Password',
'complete' => 'Username',
];
$currentIndex = array_search($currentStep ?? 'email', array_keys($steps), true);
if ($currentIndex === false) {
$currentIndex = 0;
}
@endphp
<div class="mb-6">
<div class="flex items-center justify-between text-xs sm:text-sm text-gray-600">
@foreach($steps as $key => $label)
@php $idx = array_search($key, array_keys($steps), true); @endphp
<span class="{{ $idx <= $currentIndex ? 'text-gray-900 font-semibold' : '' }}">
{{ $label }}
</span>
@endforeach
</div>
<div class="mt-2 h-2 w-full bg-gray-200 rounded-full overflow-hidden">
<div class="h-full bg-gray-900 rounded-full" style="width: {{ (($currentIndex + 1) / count($steps)) * 100 }}%"></div>
</div>
</div>

View File

@@ -0,0 +1,86 @@
@extends('layouts.nova')
@section('content')
<div class="px-4 py-8 md:px-6 md:py-10">
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
<div class="mb-4 text-sm text-sb-muted">
<p class="font-medium text-white">Check your inbox</p>
@if($email !== '')
<p class="mt-1">We sent a verification link to <strong class="text-white">{{ $email }}</strong>.</p>
<p class="mt-1">Click the link in that email to continue setup.</p>
@else
<p class="mt-1">Enter your email to resend verification if needed.</p>
@endif
</div>
@if (session('status'))
<div class="mb-4 rounded-md border border-green-700/60 bg-green-900/20 px-3 py-2 text-sm text-green-300">
{{ session('status') }}
</div>
@endif
@if ($errors->any())
<div class="mb-4 rounded-md border border-red-700/60 bg-red-900/20 px-3 py-2 text-sm text-red-300">
{{ $errors->first() }}
</div>
@endif
<form method="POST" action="{{ route('register.resend') }}" class="space-y-4" id="resend-form" data-resend-seconds="{{ (int) $resendSeconds }}">
@csrf
<div>
<x-input-label for="email" value="Email" class="text-sb-muted" />
<x-text-input id="email" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="email" name="email" :value="old('email', $email)" required autocomplete="email" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<div class="flex items-center justify-between gap-3">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
<a class="underline text-sm text-sb-muted hover:text-white" href="{{ route('login') }}">Back to login</a>
<a class="underline text-sm text-sb-muted hover:text-white" href="{{ route('register', ['email' => $email]) }}">Change email</a>
</div>
<x-primary-button id="resend-btn" class="justify-center" type="submit">
Resend verification email
</x-primary-button>
</div>
<p id="resend-timer" class="text-xs text-sb-muted"></p>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('resend-form');
const button = document.getElementById('resend-btn');
const timerText = document.getElementById('resend-timer');
if (!form || !button || !timerText) return;
let remaining = parseInt(form.dataset.resendSeconds || '0', 10);
const render = () => {
if (remaining > 0) {
button.setAttribute('disabled', 'disabled');
timerText.textContent = `You can resend in ${remaining}s.`;
} else {
button.removeAttribute('disabled');
timerText.textContent = 'Did not receive it? You can resend now.';
}
};
render();
if (remaining <= 0) return;
const interval = setInterval(() => {
remaining -= 1;
render();
if (remaining <= 0) {
clearInterval(interval);
}
}, 1000);
});
</script>
@endsection

View File

@@ -1,52 +1,41 @@
<x-guest-layout>
<form method="POST" action="{{ route('register') }}">
@csrf
@extends('layouts.nova')
<!-- Name -->
<div>
<x-input-label for="name" :value="__('Name')" />
<x-text-input id="name" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>
@section('content')
<div class="px-4 py-8 md:px-6 md:py-10">
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
<h1 class="text-2xl font-semibold text-white">Create Account</h1>
<p class="mt-2 text-sm text-sb-muted">Start with your email. You will set your password and username after verification.</p>
<!-- Email Address -->
<div class="mt-4">
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<form method="POST" action="{{ route('register') }}" class="mt-6 space-y-4">
@csrf
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<div style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;" aria-hidden="true">
<label for="website">Website</label>
<input id="website" type="text" name="website" tabindex="-1" autocomplete="off" />
</div>
<x-text-input id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="new-password" />
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" class="text-sb-muted" />
<x-text-input id="email" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="email" name="email" :value="old('email', $prefillEmail ?? '')" required autofocus autocomplete="email" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
@if(config('services.recaptcha.enabled'))
<input type="hidden" name="g-recaptcha-response" value="{{ old('g-recaptcha-response') }}" />
<x-input-error :messages="$errors->get('captcha')" class="mt-2" />
@endif
<!-- Confirm Password -->
<div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
<div class="flex items-center justify-between pt-2">
<a class="underline text-sm text-sb-muted hover:text-white" href="{{ route('login') }}">
{{ __('Already registered?') }}
</a>
<x-text-input id="password_confirmation" class="block mt-1 w-full"
type="password"
name="password_confirmation" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<a class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" href="{{ route('login') }}">
{{ __('Already registered?') }}
</a>
<x-primary-button class="ms-4">
{{ __('Register') }}
</x-primary-button>
</div>
</form>
</x-guest-layout>
<x-primary-button class="justify-center">
{{ __('Register') }}
</x-primary-button>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -1,39 +1,48 @@
<x-guest-layout>
<form method="POST" action="{{ route('password.store') }}">
@csrf
@extends('layouts.nova')
<!-- Password Reset Token -->
<input type="hidden" name="token" value="{{ $request->route('token') }}">
@section('content')
<div class="px-4 py-8 md:px-6 md:py-10">
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
<h1 class="text-2xl font-semibold text-white">Reset Password</h1>
<p class="mt-2 text-sm text-sb-muted">Choose a new password for your account.</p>
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<form method="POST" action="{{ route('password.store') }}" class="mt-4">
@csrf
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Password Reset Token -->
<input type="hidden" name="token" value="{{ $request->route('token') }}">
<!-- Confirm Password -->
<div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" class="text-sb-muted" />
<x-text-input id="email" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<x-text-input id="password_confirmation" class="block mt-1 w-full"
type="password"
name="password_confirmation" required autocomplete="new-password" />
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" class="text-sb-muted" />
<x-text-input id="password" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="password" name="password" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
</div>
<!-- Confirm Password -->
<div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" class="text-sb-muted" />
<div class="flex items-center justify-end mt-4">
<x-primary-button>
{{ __('Reset Password') }}
</x-primary-button>
</div>
</form>
</x-guest-layout>
<x-text-input id="password_confirmation" class="block mt-1 w-full bg-black/20 border-sb-line text-white"
type="password"
name="password_confirmation" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button>
{{ __('Reset Password') }}
</x-primary-button>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -0,0 +1,44 @@
@extends('layouts.nova')
@section('content')
<div class="px-4 py-8 md:px-6 md:py-10">
<div class="mx-auto w-full max-w-2xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
<h1 class="text-2xl font-semibold text-white">Set Your Password</h1>
<div class="mt-4 text-white/90">
@include('auth.partials.onboarding-progress', ['currentStep' => 'verified'])
@if (session('status'))
<div class="mb-4 rounded-md border border-green-700/60 bg-green-900/20 px-3 py-2 text-sm text-green-300">
{{ session('status') }}
</div>
@endif
<p class="mb-4 text-sm text-sb-muted">
{{ __('Create a password for ') }}<strong>{{ $email }}</strong>
</p>
<form method="POST" action="{{ route('setup.password.store') }}">
@csrf
<div>
<x-input-label for="password" :value="__('Password')" class="text-sb-muted" />
<x-text-input id="password" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="password" name="password" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
<p class="mt-2 text-xs text-sb-muted">{{ __('Minimum 10 characters, include at least one number and one symbol.') }}</p>
</div>
<div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" class="text-sb-muted" />
<x-text-input id="password_confirmation" class="block mt-1 w-full bg-black/20 border-sb-line text-white" type="password" name="password_confirmation" required autocomplete="new-password" />
</div>
<div class="mt-6 flex justify-end">
<x-primary-button class="w-full sm:w-auto justify-center">
{{ __('Continue') }}
</x-primary-button>
</div>
</form>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,52 @@
@extends('layouts.nova')
@section('content')
<div class="px-4 py-8 md:px-6 md:py-10">
<div class="mx-auto w-full max-w-2xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
<h1 class="text-2xl font-semibold text-white">Choose Username</h1>
<div class="mt-4 text-white/90">
@include('auth.partials.onboarding-progress', ['currentStep' => 'password'])
@if (session('status'))
<div class="mb-4 rounded-md border border-green-700/60 bg-green-900/20 px-3 py-2 text-sm text-green-300">
{{ session('status') }}
</div>
@endif
@if ($errors->any())
<div class="mb-4 rounded-md border border-red-700/60 bg-red-900/20 px-3 py-2 text-sm text-red-300">
{{ $errors->first() }}
</div>
@endif
<form method="POST" action="{{ route('setup.username.store') }}">
@csrf
<div>
<x-input-label for="username" :value="__('Username')" class="text-sb-muted" />
<x-text-input
id="username"
class="block mt-1 w-full bg-black/20 border-sb-line text-white"
type="text"
name="username"
:value="old('username', $username)"
required
autocomplete="username"
data-username-field="true"
data-availability-url="{{ route('api.username.availability') }}"
data-availability-target="setup-username-availability"
/>
<p id="setup-username-availability" class="mt-1 text-xs text-sb-muted"></p>
<x-input-error :messages="$errors->get('username')" class="mt-2" />
</div>
<div class="mt-6 flex justify-end">
<x-primary-button class="w-full sm:w-auto justify-center">
{{ __('Complete Setup') }}
</x-primary-button>
</div>
</form>
</div>
</div>
</div>
@endsection

View File

@@ -1,31 +1,36 @@
<x-guest-layout>
<div class="mb-4 text-sm text-gray-600">
{{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }}
</div>
@extends('layouts.nova')
@if (session('status') == 'verification-link-sent')
<div class="mb-4 font-medium text-sm text-green-600">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</div>
@endif
@section('content')
<div class="px-4 py-8 md:px-6 md:py-10">
<div class="mx-auto w-full max-w-xl rounded-2xl border border-sb-line bg-panel-dark shadow-sb p-6 md:p-8">
<h1 class="text-2xl font-semibold text-white">Verify Your Email</h1>
<p class="mt-2 text-sm text-sb-muted">Before getting started, please verify your email address by clicking the link we sent you.</p>
<div class="mt-4 flex items-center justify-between">
<form method="POST" action="{{ route('verification.send') }}">
@csrf
<div>
<x-primary-button>
{{ __('Resend Verification Email') }}
</x-primary-button>
@if (session('status') == 'verification-link-sent')
<div class="mb-4 rounded-md border border-green-700/60 bg-green-900/20 px-3 py-2 text-sm text-green-300">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</div>
</form>
@endif
<form method="POST" action="{{ route('logout') }}">
@csrf
<div class="mt-4 flex items-center justify-between">
<form method="POST" action="{{ route('verification.send') }}">
@csrf
<button type="submit" class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
{{ __('Log Out') }}
</button>
</form>
<div>
<x-primary-button>
{{ __('Resend Verification Email') }}
</x-primary-button>
</div>
</form>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="underline text-sm text-sb-muted hover:text-white rounded-md">
{{ __('Log Out') }}
</button>
</form>
</div>
</div>
</x-guest-layout>
</div>
@endsection

View File

@@ -1,63 +0,0 @@
@extends('layouts.nova')
@php
use Carbon\Carbon;
use Illuminate\Support\Str;
@endphp
@section('content')
<div class="legacy-page">
<div class="mb-6">
<h1 class="text-2xl font-semibold text-white">Forum</h1>
<p class="mt-1 text-sm text-zinc-300">Browse forum sections and latest activity.</p>
</div>
<div class="overflow-hidden rounded-lg border border-white/10 bg-zinc-900/70">
<div class="border-b border-white/10 px-4 py-3 text-sm font-semibold text-zinc-100">Forum Sections</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-white/10 text-sm">
<thead class="bg-zinc-800/60 text-zinc-300">
<tr>
<th class="px-4 py-3 text-left font-medium">Section</th>
<th class="px-4 py-3 text-center font-medium">Posts</th>
<th class="px-4 py-3 text-center font-medium">Topics</th>
<th class="px-4 py-3 text-right font-medium">Last Update</th>
</tr>
</thead>
<tbody class="divide-y divide-white/10 text-zinc-100">
@forelse (($topics ?? []) as $topic)
@php
$topicId = (int) ($topic->topic_id ?? $topic->id ?? 0);
$topicTitle = $topic->topic ?? $topic->title ?? $topic->name ?? 'Untitled';
$topicSlug = Str::slug($topicTitle);
$topicUrl = $topicId > 0 ? route('legacy.forum.topic', ['topic_id' => $topicId, 'slug' => $topicSlug]) : '#';
@endphp
<tr class="hover:bg-white/5">
<td class="px-4 py-3 align-top">
<a class="font-medium text-sky-300 hover:text-sky-200" href="{{ $topicUrl }}">{{ $topicTitle }}</a>
@if (!empty($topic->discuss))
<div class="mt-1 text-xs text-zinc-400">{!! Str::limit(strip_tags((string) $topic->discuss), 180) !!}</div>
@endif
</td>
<td class="px-4 py-3 text-center text-zinc-300">{{ $topic->num_posts ?? 0 }}</td>
<td class="px-4 py-3 text-center text-zinc-300">{{ $topic->num_subtopics ?? 0 }}</td>
<td class="px-4 py-3 text-right text-zinc-400">
@if (!empty($topic->last_update))
{{ Carbon::parse($topic->last_update)->format('d.m.Y H:i') }}
@else
-
@endif
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-6 text-center text-zinc-400">No forum sections available.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
@endsection

View File

@@ -1,69 +0,0 @@
@extends('layouts.nova')
@php
use Carbon\Carbon;
use Illuminate\Support\Str;
@endphp
@section('content')
@php
$headerTitle = data_get($topic ?? null, 'topic')
?? data_get($topic ?? null, 'title')
?? data_get($thread ?? null, 'title')
?? 'Thread';
$headerDesc = data_get($topic ?? null, 'discuss')
?? data_get($thread ?? null, 'content');
@endphp
<div class="legacy-page">
<div class="mb-6">
<a href="{{ route('legacy.forum.index') }}" class="text-sm text-sky-300 hover:text-sky-200"> Back to forum</a>
<h1 class="mt-2 text-2xl font-semibold text-white">{{ $headerTitle }}</h1>
@if (!empty($headerDesc))
<p class="mt-1 text-sm text-zinc-300">{!! Str::limit(strip_tags((string) $headerDesc), 260) !!}</p>
@endif
</div>
<div class="space-y-4">
@forelse (($posts ?? []) as $post)
@php
$authorName = $post->uname ?? data_get($post, 'user.name') ?? 'Anonymous';
$authorId = $post->user_id ?? data_get($post, 'user.id');
$postBody = $post->message ?? $post->content ?? '';
$postedAt = $post->post_date ?? $post->created_at ?? null;
@endphp
<article class="overflow-hidden rounded-lg border border-white/10 bg-zinc-900/70">
<header class="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div class="text-sm font-semibold text-zinc-100">{{ $authorName }}</div>
<div class="text-xs text-zinc-400">
@if (!empty($postedAt))
{{ Carbon::parse($postedAt)->format('d.m.Y H:i') }}
@endif
</div>
</header>
<div class="px-4 py-4">
<div class="prose prose-invert max-w-none text-sm leading-6">
{!! $postBody !!}
</div>
@if (!empty($authorId))
<div class="mt-4 text-xs text-zinc-500">
User ID: {{ $authorId }}
</div>
@endif
</div>
</article>
@empty
<div class="rounded-lg border border-white/10 bg-zinc-900/70 px-4 py-6 text-center text-zinc-400">
No posts yet.
</div>
@endforelse
</div>
@if (isset($posts) && method_exists($posts, 'links'))
<div class="mt-4">{{ $posts->withQueryString()->links() }}</div>
@endif
</div>
@endsection

View File

@@ -0,0 +1 @@
@include('forum.components.category-card', ['category' => $category])

View File

@@ -0,0 +1 @@
@include('forum.thread.components.attachment-list', ['attachments' => $attachments])

View File

@@ -0,0 +1 @@
@include('forum.thread.components.author-badge', ['user' => $user])

View File

@@ -0,0 +1 @@
@include('forum.thread.components.breadcrumbs', ['thread' => $thread, 'category' => $category])

View File

@@ -0,0 +1 @@
@include('forum.thread.components.post-card', ['post' => $post, 'thread' => $thread ?? null, 'isOp' => $isOp ?? false])

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify your email</title>
</head>
<body style="margin:0;padding:20px;background:#0b0f14;font-family:system-ui,-apple-system,Segoe UI,Roboto,'Helvetica Neue',Arial;color:#e6eef6;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;margin:24px auto;border-radius:12px;border:1px solid #1f2937;background:#081016;overflow:hidden;">
<tr>
<td style="padding:20px 24px;border-bottom:1px solid #111827;background:#071018;">
<h2 style="margin:0;font-size:18px;color:#fff;font-weight:600;">{{ config('app.name', 'Skinbase') }}</h2>
</td>
</tr>
<tr>
<td style="padding:24px;background:linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0));">
<p style="margin:0 0 12px;color:#cbd5e1;">Welcome to {{ config('app.name', 'Skinbase') }} thanks for signing up.</p>
<p style="margin:0 0 18px;color:#cbd5e1;">Please verify your email to continue account setup.</p>
<div style="text-align:center;margin:20px 0;">
<a href="{{ $verificationUrl }}" style="display:inline-block;padding:12px 20px;background:#0ea5a9;color:#06121a;text-decoration:none;border-radius:8px;font-weight:600;">Verify Email</a>
</div>
<p style="margin:0 0 8px;color:#9fb0c8;font-size:13px;">This link expires in {{ $expiresInHours }} hours.</p>
<p style="margin:12px 0 0;color:#9fb0c8;font-size:13px;">Need help? Contact support: <a href="{{ $supportUrl }}" style="color:#8bd0d3;">{{ $supportUrl }}</a></p>
</td>
</tr>
<tr>
<td style="padding:12px 24px;background:#040607;border-top:1px solid #0e1113;text-align:center;color:#6b7280;font-size:12px;">© {{ date('Y') }} {{ config('app.name', 'Skinbase') }}. All rights reserved.</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,27 @@
@extends('layouts.nova')
@section('content')
<div class="legacy-page max-w-3xl">
<div class="mb-6">
<a href="{{ route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug]) }}" class="text-sm text-sky-300 hover:text-sky-200"> Back to thread</a>
<h1 class="mt-2 text-2xl font-semibold text-white">Edit post</h1>
</div>
<form method="POST" action="{{ route('forum.post.update', ['post' => $post->id]) }}" class="space-y-4 rounded-lg border border-white/10 bg-zinc-900/70 p-4">
@csrf
@method('PUT')
<div>
<label for="content" class="mb-1 block text-sm font-medium text-zinc-200">Content</label>
<textarea id="content" name="content" rows="10" required class="w-full rounded border border-white/10 bg-zinc-950 px-3 py-2 text-zinc-100">{{ old('content', $post->content) }}</textarea>
@error('content')
<div class="mt-1 text-xs text-red-400">{{ $message }}</div>
@enderror
</div>
<div>
<button type="submit" class="rounded bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500">Save changes</button>
</div>
</form>
</div>
@endsection

View File

@@ -0,0 +1,34 @@
@extends('layouts.nova')
@section('content')
<div class="legacy-page max-w-3xl">
<div class="mb-6">
<a href="{{ route('forum.category.show', ['category' => $category->slug]) }}" class="text-sm text-sky-300 hover:text-sky-200"> Back to section</a>
<h1 class="mt-2 text-2xl font-semibold text-white">Create thread in {{ $category->name }}</h1>
</div>
<form method="POST" action="{{ route('forum.thread.store', ['category' => $category->slug]) }}" class="space-y-4 rounded-lg border border-white/10 bg-zinc-900/70 p-4">
@csrf
<div>
<label for="title" class="mb-1 block text-sm font-medium text-zinc-200">Title</label>
<input id="title" name="title" value="{{ old('title') }}" required maxlength="255" class="w-full rounded border border-white/10 bg-zinc-950 px-3 py-2 text-zinc-100" />
@error('title')
<div class="mt-1 text-xs text-red-400">{{ $message }}</div>
@enderror
</div>
<div>
<label for="content" class="mb-1 block text-sm font-medium text-zinc-200">Content</label>
<textarea id="content" name="content" rows="10" required class="w-full rounded border border-white/10 bg-zinc-950 px-3 py-2 text-zinc-100">{{ old('content') }}</textarea>
@error('content')
<div class="mt-1 text-xs text-red-400">{{ $message }}</div>
@enderror
</div>
<div>
<button type="submit" class="rounded bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500">Publish thread</button>
</div>
</form>
</div>
@endsection

View File

@@ -8,11 +8,16 @@
@section('content')
<div class="legacy-page">
<div class="mb-6">
<a href="{{ route('legacy.forum.index') }}" class="text-sm text-sky-300 hover:text-sky-200"> Back to forum</a>
<a href="{{ route('forum.index') }}" class="text-sm text-sky-300 hover:text-sky-200"> Back to forum</a>
<h1 class="mt-2 text-2xl font-semibold text-white">{{ $topic->topic ?? $topic->title ?? 'Topic' }}</h1>
@if (!empty($topic->discuss))
<p class="mt-1 text-sm text-zinc-300">{!! Str::limit(strip_tags((string) $topic->discuss), 220) !!}</p>
@endif
@if (isset($category) && auth()->check())
<div class="mt-3">
<a href="{{ route('forum.thread.create', ['category' => $category->slug]) }}" class="rounded bg-sky-600 px-3 py-2 text-xs font-medium text-white hover:bg-sky-500">New thread</a>
</div>
@endif
</div>
<div class="overflow-hidden rounded-lg border border-white/10 bg-zinc-900/70">
@@ -35,7 +40,7 @@
@endphp
<tr class="hover:bg-white/5">
<td class="px-4 py-3 align-top">
<a class="font-medium text-sky-300 hover:text-sky-200" href="{{ route('legacy.forum.topic', ['topic_id' => $id, 'slug' => Str::slug($title)]) }}">{{ $title }}</a>
<a class="font-medium text-sky-300 hover:text-sky-200" href="{{ route('forum.thread.show', ['thread' => $id, 'slug' => Str::slug($title)]) }}">{{ $title }}</a>
@if (!empty($sub->discuss))
<div class="mt-1 text-xs text-zinc-400">{!! Str::limit(strip_tags((string) $sub->discuss), 180) !!}</div>
@endif

View File

@@ -0,0 +1,50 @@
@php
$name = data_get($category, 'name', 'Untitled');
$slug = data_get($category, 'slug');
$categoryUrl = !empty($slug) ? route('forum.category.show', ['category' => $slug]) : '#';
$threads = (int) data_get($category, 'thread_count', 0);
$posts = (int) data_get($category, 'post_count', 0);
$lastActivity = data_get($category, 'last_activity_at');
$preview = data_get($category, 'preview_image', config('forum.preview_images.default'));
@endphp
<a
href="{{ $categoryUrl }}"
aria-label="Open {{ $name }} category"
role="listitem"
class="group relative block overflow-hidden rounded-xl border border-white/5 bg-slate-900/80 shadow-xl backdrop-blur transition-all duration-300 hover:shadow-cyan-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
>
<div class="relative aspect-[4/3] sm:aspect-[16/9]">
<img
src="{{ $preview }}"
alt="{{ $name }} preview"
loading="lazy"
decoding="async"
class="h-full w-full object-cover object-center transition-transform duration-300 group-hover:scale-[1.02]"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent"></div>
<div class="absolute inset-x-0 bottom-0 p-4">
<div class="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-400/15 text-cyan-300">
<i class="fa-solid fa-comments" aria-hidden="true"></i>
</div>
<h3 class="text-lg font-semibold text-white">{{ $name }}</h3>
<p class="mt-1 text-xs text-white/60">
Last activity:
@if ($lastActivity)
<time datetime="{{ \Illuminate\Support\Carbon::parse($lastActivity)->toIso8601String() }}">
{{ \Illuminate\Support\Carbon::parse($lastActivity)->diffForHumans() }}
</time>
@else
No activity yet
@endif
</p>
<div class="mt-3 flex items-center gap-4 text-sm text-cyan-300">
<span>{{ number_format($posts) }} posts</span>
<span>{{ number_format($threads) }} topics</span>
</div>
</div>
</div>
</a>

View File

@@ -0,0 +1,24 @@
@extends('layouts.nova')
@section('content')
<main class="min-h-screen bg-slate-950 px-4 py-10" aria-labelledby="forum-page-title">
<div class="mx-auto max-w-7xl">
<header class="mb-8">
<h1 id="forum-page-title" class="text-3xl font-semibold text-white">Forum</h1>
<p class="mt-2 text-sm text-white/60">Browse forum sections and latest activity.</p>
</header>
@if (($categories ?? collect())->isEmpty())
<div class="rounded-xl border border-white/10 bg-slate-900/60 p-8 text-center text-white/70">
No forum categories available yet.
</div>
@else
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3" role="list" aria-label="Forum categories">
@foreach ($categories as $category)
<x-forum.category-card :category="$category" />
@endforeach
</div>
@endif
</div>
</main>
@endsection

View File

@@ -0,0 +1,84 @@
@php
$attachments = collect($attachments ?? []);
$filesBaseUrl = rtrim((string) config('cdn.files_url', ''), '/');
$toUrl = function (?string $path) use ($filesBaseUrl): string {
$cleanPath = ltrim((string) $path, '/');
return $filesBaseUrl !== '' ? ($filesBaseUrl . '/' . $cleanPath) : ('/' . $cleanPath);
};
$formatBytes = function ($bytes): string {
$size = max((int) $bytes, 0);
if ($size < 1024) {
return $size . ' B';
}
$units = ['KB', 'MB', 'GB'];
$value = $size / 1024;
$unitIndex = 0;
while ($value >= 1024 && $unitIndex < count($units) - 1) {
$value /= 1024;
$unitIndex++;
}
return number_format($value, 1) . ' ' . $units[$unitIndex];
};
@endphp
@if ($attachments->isNotEmpty())
<div class="mt-4 space-y-3 border-t border-white/10 pt-4">
<h4 class="text-xs font-semibold uppercase tracking-wide text-zinc-400">Attachments</h4>
<ul class="grid grid-cols-1 gap-3 sm:grid-cols-2">
@foreach ($attachments as $attachment)
@php
$mime = (string) ($attachment->mime_type ?? '');
$isImage = str_starts_with($mime, 'image/');
$url = $toUrl($attachment->file_path ?? '');
$modalId = 'attachment-modal-' . (string) data_get($attachment, 'id', uniqid());
@endphp
<li class="rounded-lg border border-white/10 bg-slate-900/60 p-3">
@if ($isImage)
<a href="#{{ $modalId }}" class="block overflow-hidden rounded-md border border-white/10">
<img
src="{{ $url }}"
alt="Attachment preview"
loading="lazy"
decoding="async"
class="h-36 w-full object-cover"
/>
</a>
@endif
<div class="mt-2 flex items-center justify-between gap-3 text-xs">
<span class="truncate text-zinc-300">{{ basename((string) ($attachment->file_path ?? 'file')) }}</span>
<span class="text-zinc-500">{{ $formatBytes($attachment->file_size ?? 0) }}</span>
</div>
<a href="{{ $url }}" target="_blank" rel="noopener noreferrer" class="mt-2 inline-flex text-xs font-medium text-sky-300 hover:text-sky-200">
Download
</a>
@if ($isImage)
<div id="{{ $modalId }}" class="pointer-events-none fixed inset-0 z-50 hidden bg-black/80 p-4 target:pointer-events-auto target:block" role="dialog" aria-label="Attachment preview">
<div class="mx-auto flex h-full w-full max-w-5xl items-center justify-center">
<div class="w-full overflow-hidden rounded-xl border border-white/10 bg-slate-950/95">
<div class="flex items-center justify-between border-b border-white/10 px-4 py-2">
<span class="truncate text-xs text-zinc-300">{{ basename((string) ($attachment->file_path ?? 'file')) }}</span>
<a href="#" class="text-xs text-zinc-400 hover:text-zinc-200">Close</a>
</div>
<div class="max-h-[80vh] overflow-auto p-3">
<img src="{{ $url }}" alt="Attachment full preview" class="mx-auto h-auto max-h-[72vh] w-auto max-w-full object-contain" />
</div>
<div class="border-t border-white/10 px-4 py-2 text-right">
<a href="{{ $url }}" target="_blank" rel="noopener noreferrer" class="text-xs font-medium text-sky-300 hover:text-sky-200">Open original</a>
</div>
</div>
</div>
</div>
@endif
</li>
@endforeach
</ul>
</div>
@endif

View File

@@ -0,0 +1,32 @@
@php
$user = $user ?? null;
$name = data_get($user, 'name', 'Anonymous');
$avatar = data_get($user, 'profile.avatar_url') ?? \App\Support\AvatarUrl::forUser((int) data_get($user, 'id', 0));
$role = strtolower((string) data_get($user, 'role', 'member'));
$roleLabel = match ($role) {
'admin' => 'Admin',
'moderator' => 'Moderator',
default => 'Member',
};
$roleClasses = match ($role) {
'admin' => 'bg-red-500/15 text-red-300',
'moderator' => 'bg-amber-500/15 text-amber-300',
default => 'bg-sky-500/15 text-sky-300',
};
@endphp
<div class="flex items-center gap-3">
<img
src="{{ $avatar }}"
alt="{{ $name }} avatar"
loading="lazy"
decoding="async"
class="h-10 w-10 rounded-full border border-white/10 object-cover"
/>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-zinc-100">{{ $name }}</div>
<span class="inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium {{ $roleClasses }}">{{ $roleLabel }}</span>
</div>
</div>

View File

@@ -0,0 +1,30 @@
@php
$thread = $thread ?? null;
$category = $category ?? null;
@endphp
<nav class="text-sm text-zinc-400" aria-label="Breadcrumb" itemscope itemtype="https://schema.org/BreadcrumbList">
<ol class="flex flex-wrap items-center gap-2">
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<a itemprop="item" href="{{ url('/') }}" class="hover:text-zinc-200"><span itemprop="name">Home</span></a>
<meta itemprop="position" content="1">
</li>
<li aria-hidden="true">/</li>
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<a itemprop="item" href="{{ route('forum.index') }}" class="hover:text-zinc-200"><span itemprop="name">Forum</span></a>
<meta itemprop="position" content="2">
</li>
<li aria-hidden="true">/</li>
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<a itemprop="item" href="{{ isset($category) ? route('forum.category.show', ['category' => $category->slug]) : route('forum.index') }}" class="hover:text-zinc-200">
<span itemprop="name">{{ $category->name ?? 'Category' }}</span>
</a>
<meta itemprop="position" content="3">
</li>
<li aria-hidden="true">/</li>
<li class="text-zinc-200" itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<span itemprop="name">{{ $thread->title ?? 'Thread' }}</span>
<meta itemprop="position" content="4">
</li>
</ol>
</nav>

View File

@@ -0,0 +1,71 @@
@php
$post = $post ?? null;
$thread = $thread ?? null;
$isOp = (bool) ($isOp ?? false);
$author = data_get($post, 'user');
$postedAt = data_get($post, 'created_at');
$editedAt = data_get($post, 'edited_at');
$content = (string) data_get($post, 'content', '');
$rendered = \App\Support\ForumPostContent::render($content);
@endphp
<article class="overflow-hidden rounded-xl border border-white/5 bg-slate-900/70 backdrop-blur" id="post-{{ data_get($post, 'id') }}">
<header class="border-b border-white/10 px-4 py-3 sm:px-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<x-forum.thread.author-badge :user="$author" />
<div class="text-xs text-zinc-400">
@if ($postedAt)
<time datetime="{{ \Illuminate\Support\Carbon::parse($postedAt)->toIso8601String() }}">
{{ \Illuminate\Support\Carbon::parse($postedAt)->format('d.m.Y H:i') }}
</time>
@endif
@if ($isOp)
<span class="ml-2 rounded-full bg-cyan-500/15 px-2 py-0.5 text-[11px] font-medium text-cyan-300">OP</span>
@endif
</div>
</div>
</header>
<div class="px-4 py-4 sm:px-5">
<div class="prose prose-invert max-w-none text-sm leading-6 prose-pre:overflow-x-auto">
{!! $rendered !!}
</div>
@if (data_get($post, 'is_edited') && $editedAt)
<p class="mt-3 text-xs text-zinc-500">
Edited <time datetime="{{ \Illuminate\Support\Carbon::parse($editedAt)->toIso8601String() }}">{{ \Illuminate\Support\Carbon::parse($editedAt)->diffForHumans() }}</time>
</p>
@endif
<x-forum.thread.attachment-list :attachments="data_get($post, 'attachments', [])" />
</div>
<footer class="flex flex-wrap items-center gap-3 border-t border-white/10 px-4 py-3 text-xs text-zinc-400 sm:px-5">
<button type="button" disabled aria-disabled="true" title="Like coming soon" class="cursor-not-allowed rounded border border-white/10 px-2 py-0.5 text-zinc-500">Like</button>
@if (!empty(data_get($thread, 'id')))
<a href="{{ route('forum.thread.show', ['thread' => data_get($thread, 'id'), 'slug' => data_get($thread, 'slug'), 'quote' => data_get($post, 'id')]) }}#reply-content" class="hover:text-zinc-200">Quote</a>
@else
<a href="#post-{{ data_get($post, 'id') }}" class="hover:text-zinc-200">Quote</a>
@endif
@auth
@if ((int) data_get($post, 'user_id') !== (int) auth()->id())
<form method="POST" action="{{ route('forum.post.report', ['post' => data_get($post, 'id')]) }}" class="inline">
@csrf
<button type="submit" class="hover:text-zinc-200">Report</button>
</form>
@endif
@else
<a href="{{ route('login') }}" class="hover:text-zinc-200">Report</a>
@endauth
@auth
@if ((int) data_get($post, 'user_id') === (int) auth()->id() || Gate::allows('moderate-forum'))
<a href="{{ route('forum.post.edit', ['post' => data_get($post, 'id')]) }}" class="hover:text-zinc-200">Edit</a>
@endif
@endauth
@can('moderate-forum')
<span class="ml-auto text-amber-300">Moderation tools available</span>
@endcan
</footer>
</article>

View File

@@ -0,0 +1,124 @@
@extends('layouts.nova')
@section('content')
<main class="min-h-screen bg-slate-950 px-4 py-8" aria-labelledby="thread-title">
<div class="mx-auto max-w-5xl space-y-5">
<x-forum.thread.breadcrumbs :thread="$thread" :category="$category" />
@if (session('status'))
<div class="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-300">
{{ session('status') }}
</div>
@endif
<section class="rounded-xl border border-white/5 bg-slate-900/70 p-5 backdrop-blur">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 id="thread-title" class="text-2xl font-semibold text-white">{{ $thread->title }}</h1>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-zinc-400">
<span>By {{ $author->name ?? 'Unknown' }}</span>
<span aria-hidden="true"></span>
<time datetime="{{ optional($thread->created_at)?->toIso8601String() }}">{{ optional($thread->created_at)?->format('d.m.Y H:i') }}</time>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-full bg-sky-500/15 px-2.5 py-1 text-sky-300">{{ number_format((int) ($thread->views ?? 0)) }} views</span>
<span class="rounded-full bg-cyan-500/15 px-2.5 py-1 text-cyan-300">{{ number_format((int) ($reply_count ?? 0)) }} replies</span>
@if ($thread->is_pinned)
<span class="rounded-full bg-amber-500/15 px-2.5 py-1 text-amber-300">Pinned</span>
@endif
@if ($thread->is_locked)
<span class="rounded-full bg-red-500/15 px-2.5 py-1 text-red-300">Locked</span>
@endif
</div>
</div>
@can('moderate-forum')
<div class="mt-4 flex flex-wrap items-center gap-2 border-t border-white/10 pt-3 text-xs">
@if ($thread->is_locked)
<form method="POST" action="{{ route('forum.thread.unlock', ['thread' => $thread->id]) }}">
@csrf
<button type="submit" class="rounded-md bg-red-500/15 px-2.5 py-1 text-red-300 hover:bg-red-500/25">Unlock thread</button>
</form>
@else
<form method="POST" action="{{ route('forum.thread.lock', ['thread' => $thread->id]) }}">
@csrf
<button type="submit" class="rounded-md bg-red-500/15 px-2.5 py-1 text-red-300 hover:bg-red-500/25">Lock thread</button>
</form>
@endif
@if ($thread->is_pinned)
<form method="POST" action="{{ route('forum.thread.unpin', ['thread' => $thread->id]) }}">
@csrf
<button type="submit" class="rounded-md bg-amber-500/15 px-2.5 py-1 text-amber-300 hover:bg-amber-500/25">Unpin thread</button>
</form>
@else
<form method="POST" action="{{ route('forum.thread.pin', ['thread' => $thread->id]) }}">
@csrf
<button type="submit" class="rounded-md bg-amber-500/15 px-2.5 py-1 text-amber-300 hover:bg-amber-500/25">Pin thread</button>
</form>
@endif
</div>
@endcan
</section>
@if (isset($opPost) && $opPost)
<x-forum.thread.post-card :post="$opPost" :thread="$thread" :is-op="true" />
@endif
<section class="space-y-4" aria-label="Replies">
@forelse ($posts as $post)
<x-forum.thread.post-card :post="$post" :thread="$thread" />
@empty
<div class="rounded-xl border border-white/10 bg-slate-900/60 px-4 py-6 text-center text-zinc-400">
No replies yet.
</div>
@endforelse
</section>
@if (method_exists($posts, 'links'))
<div class="sticky bottom-3 z-10 rounded-xl border border-white/10 bg-slate-900/80 p-2 backdrop-blur supports-[backdrop-filter]:bg-slate-900/70">
{{ $posts->withQueryString()->links() }}
</div>
@endif
@auth
@if (!$thread->is_locked)
<form method="POST" action="{{ route('forum.thread.reply', ['thread' => $thread->id]) }}" class="space-y-3 rounded-xl border border-white/5 bg-slate-900/70 p-4 backdrop-blur">
@csrf
<div class="flex items-center justify-between">
<label for="reply-content" class="text-sm font-medium text-zinc-200">Reply</label>
<span class="text-xs text-zinc-500">Minimum 2 characters</span>
</div>
<div class="rounded-lg border border-white/10 bg-slate-950 p-2">
<div class="mb-2 flex items-center gap-2 text-xs">
<button type="button" class="rounded bg-slate-800 px-2 py-1 text-zinc-200" aria-pressed="true">Write</button>
<span class="rounded bg-slate-900 px-2 py-1 text-zinc-500">Preview (coming soon)</span>
</div>
<textarea id="reply-content" name="content" rows="6" required minlength="2" maxlength="10000" class="w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-zinc-100 placeholder-zinc-500 focus:border-cyan-400 focus:outline-none focus:ring-1 focus:ring-cyan-400">{{ $reply_prefill ?? old('content') }}</textarea>
</div>
@error('content')
<p class="text-xs text-red-400">{{ $message }}</p>
@enderror
@if (!empty($quoted_post))
<p class="text-xs text-cyan-300">Replying with quote from {{ data_get($quoted_post, 'user.name', 'Anonymous') }}.</p>
@endif
<div class="flex items-center justify-between">
<p class="text-xs text-zinc-500">Markdown/BBCode + attachments will be enabled in next pass</p>
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400">Post reply</button>
</div>
</form>
@else
<div class="rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3 text-sm text-red-300">
This thread is locked. Replies are disabled.
</div>
@endif
@else
<div class="rounded-xl border border-white/10 bg-slate-900/60 px-4 py-4 text-sm text-zinc-300">
<a href="{{ route('login') }}" class="text-sky-300 hover:text-sky-200">Sign in</a> to post a reply.
</div>
@endauth
</div>
</main>
@endsection

View File

@@ -10,18 +10,25 @@
<div class="pt-0">
<div class="mx-auto w-full">
<div class="flex min-h-[calc(100vh-64px)]">
<div class="relative flex min-h-[calc(100vh-64px)]">
<button
id="sidebar-toggle"
type="button"
class="hidden md:inline-flex items-center justify-center h-10 w-10 rounded-lg border border-white/10 bg-white/5 text-white/90 hover:bg-white/10 absolute top-3 z-20"
aria-controls="sidebar"
aria-expanded="true"
aria-label="Toggle sidebar"
style="left:16px;"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nova-900/60 backdrop-blur-sm">
<div class="p-4">
<button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
</span>
<span class="text-sm text-white/90">Menu</span>
</button>
<div class="mt-6 text-sm text-neutral-400">
<div class="mt-2 text-sm text-neutral-400">
<div class="font-semibold text-white/80 mb-2">Main Categories:</div>
<ul class="space-y-2">
@foreach($mainCategories as $main)
@@ -32,7 +39,7 @@
</ul>
<div class="mt-6 font-semibold text-white/80 mb-2">Browse Subcategories:</div>
<ul class="space-y-2 sb-scrollbar max-h-56 overflow-auto pr-2">
<ul class="space-y-2 pr-2">
@forelse($subcategories as $sub)
@php
$subName = $sub->category_name ?? $sub->name ?? null;
@@ -128,9 +135,13 @@
@media (min-width: 1024px) {
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (min-width: 2600px) {
/* Larger desktop screens: 5 columns */
@media (min-width: 1600px) {
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}
@media (min-width: 2600px) {
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
}
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
/* Keep pagination visible when JS enhances the gallery so users
have a clear navigation control (numeric links for length-aware
@@ -184,4 +195,64 @@
@push('scripts')
<script src="/js/legacy-gallery-init.js" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var toggle = document.getElementById('sidebar-toggle');
var sidebar = document.getElementById('sidebar');
if (!toggle || !sidebar) return;
var collapsed = false;
try {
collapsed = window.localStorage.getItem('gallery.sidebar.collapsed') === '1';
} catch (e) {
collapsed = false;
}
function applySidebarState() {
if (collapsed) {
sidebar.classList.add('md:hidden');
toggle.setAttribute('aria-expanded', 'false');
} else {
sidebar.classList.remove('md:hidden');
toggle.setAttribute('aria-expanded', 'true');
}
positionToggle();
}
toggle.addEventListener('click', function () {
collapsed = !collapsed;
applySidebarState();
try {
window.localStorage.setItem('gallery.sidebar.collapsed', collapsed ? '1' : '0');
} catch (e) {
// no-op
}
});
function positionToggle() {
if (!toggle || !sidebar) return;
// when sidebar is visible, position toggle just outside its right edge
if (!collapsed) {
var rect = sidebar.getBoundingClientRect();
if (rect && rect.right) {
toggle.style.left = (rect.right + 8) + 'px';
toggle.style.transform = '';
} else {
// fallback to sidebar width (18rem)
toggle.style.left = 'calc(18rem + 8px)';
}
} else {
// when collapsed, position toggle near page left edge
toggle.style.left = '16px';
toggle.style.transform = '';
}
}
window.addEventListener('resize', function () { positionToggle(); });
applySidebarState();
// ensure initial position set
positionToggle();
});
</script>
@endpush

View File

@@ -127,8 +127,12 @@
<!-- User dropdown -->
<div class="relative">
<button class="flex items-center gap-2 pl-2 pr-3 h-10 rounded-lg hover:bg-white/5" data-dd="user">
@php
$toolbarUserId = (int) ($userId ?? Auth::id() ?? 0);
$toolbarAvatarHash = $avatarHash ?? optional(Auth::user())->profile->avatar_hash ?? null;
@endphp
<img class="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
src="{{ \App\Support\AvatarUrl::forUser((int) ($userId ?? (Auth::id() ?? 0)), $avatarHash ?? null, 64) }}"
src="{{ \App\Support\AvatarUrl::forUser($toolbarUserId, $toolbarAvatarHash, 64) }}"
alt="{{ $displayName ?? 'User' }}" />
<span class="text-sm text-white/90">{{ $displayName ?? 'User' }}</span>
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor"
@@ -200,6 +204,13 @@
</a>
<div class="px-4 dd-section">System</div>
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-user-shield text-sb-muted"></i></span>
Username Moderation
</a>
@endif
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="/logout">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-sign-out text-sb-muted"></i></span>
@@ -211,7 +222,7 @@
@else
<!-- Guest: show simple Join / Sign in links -->
<div class="hidden md:flex items-center gap-3">
<a href="/signup"
<a href="/register"
class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Join</a>
<a href="/login"
class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Sign in</a>
@@ -235,6 +246,11 @@
<a class="block py-2 border-b border-neutral-900" href="/featured-artworks">Featured</a>
<a class="block py-2 border-b border-neutral-900" href="/forum">Forum</a>
<a class="block py-2 border-b border-neutral-900" href="/profile">Profile</a>
@auth
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
<a class="block py-2 border-b border-neutral-900" href="{{ route('admin.usernames.moderation') }}">Username Moderation</a>
@endif
@endauth
<a class="block py-2" href="/settings">Settings</a>
</div>
</div>

View File

@@ -1,51 +0,0 @@
@extends('layouts.legacy')
@php
use Carbon\Carbon;
@endphp
@section('content')
<div class="container-fluid legacy-page">
<div class="effect2 page-header-wrap">
<header class="page-heading">
<h1 class="page-header">Forum</h1>
<p>Latest threads</p>
</header>
</div>
<div class="panel panel-default effect2">
<div class="panel-heading"><strong>Forum Threads</strong></div>
<div class="panel-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Thread</th>
<th class="text-center">Posts</th>
<th class="text-center">Topics</th>
<th class="text-right">Last Update</th>
</tr>
</thead>
<tbody>
@forelse ($topics as $topic)
<tr>
<td>
<h4 style="margin:0;">
<a href="/forum/{{ $topic->topic_id }}/{{ Str::slug($topic->topic ?? '') }}">{{ $topic->topic }}</a>
</h4>
<div class="text-muted">{!! $topic->discuss !!}</div>
</td>
<td class="text-center">{{ $topic->num_posts ?? 0 }}</td>
<td class="text-center">{{ $topic->num_subtopics ?? 0 }}</td>
<td class="text-right">{{ $topic->last_update ? Carbon::parse($topic->last_update)->format('H:i @ d.m') : '' }}</td>
</tr>
@empty
<tr><td colspan="4">No threads available.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -1,51 +0,0 @@
@extends('layouts.legacy')
@php
use Carbon\Carbon;
@endphp
@section('content')
<div class="container-fluid legacy-page">
<div class="effect2 page-header-wrap">
<header class="page-heading">
<div class="navigation"><a class="badge" href="/forum">Forum</a></div>
<h1 class="page-header">{{ $topic->topic }}</h1>
@if (!empty($topic->discuss))
<p>{!! $topic->discuss !!}</p>
@endif
</header>
</div>
<div class="panel panel-default effect2">
<div class="panel-heading"><strong>Posts</strong></div>
<div class="panel-body">
@forelse ($posts as $post)
<div class="panel panel-default effect2" style="overflow:hidden;">
<div class="panel-heading clearfix">
<div class="pull-right text-muted">{{ $post->post_date ? Carbon::parse($post->post_date)->format('d.m.Y H:i') : '' }}</div>
<strong>{{ $post->uname ?? 'Anonymous' }}</strong>
</div>
<div class="panel-body" style="display:flex; gap:12px;">
<div style="min-width:52px;">
@if (!empty($post->user_id) && !empty($post->icon))
<img src="{{ \App\Support\AvatarUrl::forUser((int) $post->user_id, null, 50) }}" alt="{{ $post->uname }}" width="50" height="50" class="img-thumbnail">
@else
<div class="img-thumbnail" style="width:50px;height:50px;"></div>
@endif
</div>
<div style="flex:1;">
{!! $post->message !!}
</div>
</div>
</div>
@empty
<p>No posts yet.</p>
@endforelse
<div class="paginationMenu text-center">
{{ $posts->withQueryString()->links('pagination::bootstrap-4') }}
</div>
</div>
</div>
</div>
@endsection

View File

@@ -1,56 +0,0 @@
@extends('layouts.legacy')
@php
use Carbon\Carbon;
use Illuminate\Support\Str;
@endphp
@section('content')
<div class="container-fluid legacy-page">
<div class="effect2 page-header-wrap">
<header class="page-heading">
<div class="navigation"><a class="badge" href="/forum">Forum</a></div>
<h1 class="page-header">{{ $topic->topic }}</h1>
@if (!empty($topic->discuss))
<p>{!! $topic->discuss !!}</p>
@endif
</header>
</div>
<div class="panel panel-default effect2">
<div class="panel-heading"><strong>Topics</strong></div>
<div class="panel-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>#</th>
<th>Topic</th>
<th class="text-center">Opened By</th>
<th class="text-right">Posted</th>
</tr>
</thead>
<tbody>
@forelse ($subtopics as $sub)
<tr>
<td>{{ $sub->topic_id }}</td>
<td>
<a href="/forum/{{ $sub->topic_id }}/{{ Str::slug($sub->topic ?? '') }}">{{ $sub->topic }}</a>
<div class="text-muted small">{!! Str::limit(strip_tags($sub->discuss ?? ''), 160) !!}</div>
</td>
<td class="text-center">{{ $sub->uname ?? 'Unknown' }}</td>
<td class="text-right">{{ $sub->last_update ? Carbon::parse($sub->last_update)->format('d.m.Y H:i') : '' }}</td>
</tr>
@empty
<tr><td colspan="4">No topics yet.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="paginationMenu text-center">
{{ $subtopics->withQueryString()->links('pagination::bootstrap-4') }}
</div>
</div>
</div>
</div>
@endsection

View File

@@ -1,6 +1,10 @@
{{-- News and forum columns --}}
<div class="row news-row">
<div class="col-sm-6">
@php
use Carbon\Carbon;
use Illuminate\Support\Str;
@endphp
@forelse ($forumNews as $item)
<div class="panel panel-skinbase effect2">
<div class="panel-heading"><h4 class="panel-title">{{ $item->topic }}</h4></div>
@@ -10,7 +14,7 @@
</div>
{!! Str::limit(strip_tags($item->preview ?? ''), 240, '...') !!}
<br>
<a class="clearfix btn btn-xs btn-info" href="/forum/{{ $item->topic_id }}/{{ Str::slug($item->topic ?? '') }}" title="{{ strip_tags($item->topic) }}">More</a>
<a class="clearfix btn btn-xs btn-info" href="{{ route('forum.thread.show', ['thread' => $item->topic_id, 'slug' => Str::slug($item->topic ?? '')]) }}" title="{{ strip_tags($item->topic) }}">More</a>
</div>
</div>
@empty
@@ -67,7 +71,7 @@
<div class="panel-body">
<div class="list-group effect2">
@forelse ($latestForumActivity as $topic)
<a class="list-group-item" href="/forum/{{ $topic->topic_id }}/{{ Str::slug($topic->topic ?? '') }}">
<a class="list-group-item" href="{{ route('forum.thread.show', ['thread' => $topic->topic_id, 'slug' => Str::slug($topic->topic ?? '')]) }}">
{{ $topic->topic }} <span class="badge badge-info">{{ $topic->numPosts }}</span>
</a>
@empty

View File

@@ -2,6 +2,19 @@
@section('content')
@php
$birthDay = $birthDay ?? null;
$birthMonth = $birthMonth ?? null;
$birthYear = $birthYear ?? null;
$avatarUserId = (int) ($user->id ?? auth()->id() ?? 0);
$avatarHash = $user->icon
?? optional(auth()->user())->profile->avatar_hash
?? null;
$currentAvatarUrl = !empty($avatarHash)
? \App\Support\AvatarUrl::forUser($avatarUserId, $avatarHash, 128)
: \App\Support\AvatarUrl::default();
@endphp
<div class="min-h-screen bg-deep text-white py-12">
<!-- Container -->
@@ -146,7 +159,30 @@
<!-- Avatar -->
<div>
<label class="form-label">Avatar</label>
<input type="file" name="avatar" class="form-file">
<div class="rounded-xl border border-sb-line bg-black/10 p-4">
<div class="flex items-center gap-4">
<img
id="avatarPreviewImage"
src="{{ $currentAvatarUrl }}"
alt="{{ $user->name ?? $user->username ?? 'Avatar' }}"
class="w-24 h-24 rounded-full object-cover ring-1 ring-white/10"
loading="lazy"
decoding="async"
>
<div class="min-w-0 flex-1">
<button
type="button"
id="avatarDropzone"
class="w-full rounded-lg border border-dashed border-sb-line px-4 py-4 text-left hover:bg-white/5 focus:outline-none focus:ring-2 focus:ring-sb-blue/50"
>
<div class="text-sm text-white/90">Drag & drop a new avatar here</div>
<div class="text-xs text-soft mt-1">or click to browse (JPG, PNG, WEBP)</div>
<div id="avatarFileName" class="text-xs text-sb-muted mt-2 truncate">No file selected</div>
</button>
<input id="avatarInput" type="file" name="avatar" class="sr-only" accept="image/jpeg,image/png,image/webp">
</div>
</div>
</div>
</div>
<!-- Emoticon -->
@@ -245,3 +281,65 @@
</div>
@endsection
@push('scripts')
<script>
(() => {
const input = document.getElementById('avatarInput');
const dropzone = document.getElementById('avatarDropzone');
const preview = document.getElementById('avatarPreviewImage');
const fileName = document.getElementById('avatarFileName');
if (!input || !dropzone || !preview || !fileName) {
return;
}
const updatePreview = (file) => {
if (!file || !file.type || !file.type.startsWith('image/')) {
return;
}
const objectUrl = URL.createObjectURL(file);
preview.src = objectUrl;
fileName.textContent = file.name;
preview.onload = () => URL.revokeObjectURL(objectUrl);
};
dropzone.addEventListener('click', () => input.click());
input.addEventListener('change', () => {
const file = input.files && input.files[0] ? input.files[0] : null;
if (file) {
updatePreview(file);
}
});
['dragenter', 'dragover'].forEach((eventName) => {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
event.stopPropagation();
dropzone.classList.add('ring-2', 'ring-sb-blue/50');
});
});
['dragleave', 'drop'].forEach((eventName) => {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
event.stopPropagation();
dropzone.classList.remove('ring-2', 'ring-sb-blue/50');
});
});
dropzone.addEventListener('drop', (event) => {
const file = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files[0] : null;
if (!file) {
return;
}
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
updatePreview(file);
});
})();
</script>
@endpush

View File

@@ -28,6 +28,24 @@
data-initial-src="{{ $avatarInitialSrc }}"
></div>
<div>
<x-input-label for="username" :value="__('Username')" />
<x-text-input
id="username"
name="username"
type="text"
class="mt-1 block w-full"
:value="old('username', $user->username)"
required
autocomplete="username"
data-username-field="true"
data-availability-url="{{ route('api.username.availability') }}"
data-availability-target="profile-username-availability"
/>
<p id="profile-username-availability" class="mt-1 text-xs text-gray-500"></p>
<x-input-error class="mt-2" :messages="$errors->get('username')" />
</div>
<div>
<x-input-label for="name" :value="__('Name')" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $user->name)" required autofocus autocomplete="name" />

View File

@@ -1,4 +1,9 @@
{{-- News and forum columns (migrated from legacy/home/news.blade.php) --}}
@php
use Carbon\Carbon;
use Illuminate\Support\Str;
@endphp
<div class="row news-row">
<div class="col-sm-6">
@forelse ($forumNews as $item)
@@ -10,7 +15,7 @@
</div>
{!! Str::limit(strip_tags($item->preview ?? ''), 240, '...') !!}
<br>
<a class="clearfix btn btn-xs btn-info" href="/forum/{{ $item->topic_id }}/{{ Str::slug($item->topic ?? '') }}" title="{{ strip_tags($item->topic) }}">More</a>
<a class="clearfix btn btn-xs btn-info" href="{{ route('forum.thread.show', ['thread' => $item->topic_id, 'slug' => Str::slug($item->topic ?? '')]) }}" title="{{ strip_tags($item->topic) }}">More</a>
</div>
</div>
@empty
@@ -67,7 +72,7 @@
<div class="panel-body">
<div class="list-group effect2">
@forelse ($latestForumActivity as $topic)
<a class="list-group-item" href="/forum/{{ $topic->topic_id }}/{{ Str::slug($topic->topic ?? '') }}">
<a class="list-group-item" href="{{ route('forum.thread.show', ['thread' => $topic->topic_id, 'slug' => Str::slug($topic->topic ?? '')]) }}">
{{ $topic->topic }} <span class="badge badge-info">{{ $topic->numPosts }}</span>
</a>
@empty

View File

@@ -13,7 +13,14 @@
}
$title = trim((string) ($art->name ?? $art->title ?? 'Untitled artwork'));
$author = trim((string) ($art->uname ?? $art->author_name ?? $art->author ?? 'Skinbase'));
$author = trim((string) (
$art->uname
?? $art->author_name
?? $art->author
?? ($art->user->name ?? null)
?? ($art->user->username ?? null)
?? 'Skinbase'
));
$category = trim((string) ($art->category_name ?? $art->category ?? 'General'));
$license = trim((string) ($art->license ?? 'Standard'));
$resolution = trim((string) ($art->resolution ?? ((isset($art->width, $art->height) && $art->width && $art->height) ? ($art->width . '×' . $art->height) : '')));