A lot of things

- Added Database page.
- Added Xenforo API compatibility
- Added Hovercard
- Added Notifications
This commit is contained in:
2026-05-24 11:47:20 +02:00
parent 7cd6dfddda
commit a778222564
51 changed files with 3228 additions and 38 deletions

View File

@@ -12,6 +12,9 @@
@import './components/cards.css';
@import './components/modal.css';
@import './components/files.css';
@import './components/database.css';
@import './components/hovercard.css';
@import './components/notifications.css';
@import './components/easymde.css';

View File

@@ -22,3 +22,78 @@
width: 32px;
height: 32px;
}
/* ENTRY CARDS */
.entry-card {
background-color: var(--bg2);
border: 1px solid var(--border);
display: flex;
flex-direction: column;
transition: transform 0.2s, border-color 0.2s;
cursor: pointer;
&:hover {
transform: translateY(-3px);
border-color: var(--rhpz-orange);
}
.entry-cover-wrapper {
position: relative;
aspect-ratio: 4/3;
background-color: var(--bg);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.entry-cover-wrapper img {
width: 100%;
height: 100%;
object-fit: cover;
}
.entry-badge {
position: absolute;
top: 10px;
right: 10px;
background-color: rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
border: 1px solid var(--border);
padding: 4px 8px;
font-size: 0.75rem;
color: var(--text);
}
.entry-card-info {
padding: 15px;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.entry-card-title {
font-weight: 600;
color: var(--text);
font-size: 1.1rem;
margin-bottom: 5px;
line-height: 1.3;
}
.entry-card-author {
color: var(--rhpz-orange);
font-size: 0.85rem;
margin-bottom: 10px;
}
.entry-card-meta {
margin-top: auto;
font-size: 0.8rem;
color: var(--text2);
display: flex;
justify-content: space-between;
align-items: center;
}
}

View File

@@ -87,17 +87,49 @@
color: var(--text3);
border-color: var(--rhpz-orange);
}
.badge.blue {
.badge.blue, .badge.translations {
background-color: var(--info);
color: var(--text);
border-color: var(--info);
}
.badge.green {
.badge.green, .badge.romhacks {
background-color: var(--success2);
color: var(--text);
border-color: var(--success2);
}
.topbar-badge {
position: absolute;
top: 0;
right: 0;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 9px;
background-color: var(--rhpz-orange);
color: #111;
font-size: 0.65rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
border: 2px solid var(--bg);
animation: badge-pop 0.2s ease;
}
.topbar-badge--overflow {
border-radius: 9px;
padding: 0 5px;
font-size: 0.6rem;
}
@keyframes badge-pop {
0% { transform: scale(0.5); opacity: 0; }
70% { transform: scale(1.2); }
100% { transform: scale(1); opacity: 1; }
}
/* BREADCRUMB */
.breadcrumb {

View File

@@ -0,0 +1,391 @@
.filter-bar {
display: flex;
gap: 15px;
background-color: var(--bg2);
padding: 15px;
border: 1px solid var(--border);
margin-bottom: 20px;
flex-wrap: wrap;
align-items: center;
.filter-bar-search {
flex: 1;
max-width: 400px;
background-color: var(--bg);
border: 1px solid var(--border);
display: flex;
align-items: center;
padding: 8px 12px;
gap: 8px;
}
}
.database-wrapper {
display: flex;
gap: 20px;
align-items: flex-start;
.database-filters {
width: 300px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 2px;
background-color: var(--bg2);
border: 1px solid var(--border);
.filter-group {
border-bottom: 1px solid var(--border);
overflow: hidden;
&:last-child {
border-bottom: none;
}
}
.filter-title-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background-color: var(--bg3);
cursor: pointer;
user-select: none;
.filter-title {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text2);
margin: 0;
}
}
.filter-mode {
display: flex;
gap: 4px;
}
.filter-btn-mode {
background: none;
border: 1px solid var(--border);
color: var(--text2);
font-size: 0.7rem;
font-weight: 600;
padding: 2px 7px;
cursor: pointer;
font-family: var(--typography);
transition: all 0.15s;
letter-spacing: 0.5px;
&:hover {
border-color: var(--rhpz-orange);
color: var(--rhpz-orange);
}
&.active {
background-color: var(--rhpz-orange);
border-color: var(--rhpz-orange);
color: var(--text3);
}
}
.filter-options {
padding: 6px 0;
max-height: 180px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: var(--bg2);
}
&::-webkit-scrollbar-thumb {
background: var(--border);
}
}
.filter-option {
display: flex;
align-items: center;
gap: 9px;
padding: 6px 14px;
font-size: 0.88rem;
color: var(--text);
cursor: pointer;
transition: background-color 0.1s;
&:hover {
background-color: var(--bg4);
}
}
.filter-option input[type="checkbox"] {
accent-color: var(--rhpz-orange);
width: 14px;
height: 14px;
cursor: pointer;
flex-shrink: 0;
}
}
.database-results {
flex: 1;
min-width: 0;
.database-sort {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
.btn {
font-size: 0.85rem;
padding: 6px 12px;
&.active {
border-color: var(--rhpz-orange);
color: var(--rhpz-orange);
}
}
}
.database-results-count {
margin-left: auto;
font-size: 0.85rem;
color: var(--text2);
}
.database-active-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 15px;
}
.database-active-filter-tag {
display: inline-flex;
align-items: center;
gap: 6px;
background-color: var(--bg3);
border: 1px solid var(--border);
padding: 3px 10px;
font-size: 0.8rem;
color: var(--text);
.tag-type {
color: var(--rhpz-orange);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
button {
background: none;
border: none;
color: var(--text2);
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
transition: color 0.15s;
&:hover {
color: var(--text);
}
}
}
.database-empty {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--text2);
background-color: var(--bg2);
border: 1px solid var(--border);
gap: 15px;
text-align: center;
i {
color: var(--border);
}
p {
font-size: 0.95rem;
}
}
}
}
.database-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
margin-top: 20px;
.btn {
min-width: 36px;
padding: 6px 10px;
font-size: 0.85rem;
display: flex;
align-items: center;
justify-content: center;
}
.active {
background-color: var(--rhpz-orange);
border-color: var(--rhpz-orange);
color: #111;
font-weight: 600;
}
}
.database-search {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
@media (max-width: 900px) {
.database-layout {
flex-direction: column;
}
.database-filters {
width: 100%;
display: grid;
grid-template-columns: repeat(2,1fr);
}
.database-filter-group:last-child {
border-bottom: 1px solid var(--border);
}
.database-results-count {
margin-left: 0;
width: 100%;
}
}
@media (max-width: 600px) {
.database-filters {
grid-template-columns: 1fr;
}
.grid-entries {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 420px) {
.grid-entries {
grid-template-columns: 1fr;
}
}
.filter-chevron {
transition: transform 0.2s ease;
color: var(--text2);
flex-shrink: 0;
}
.filter-chevron.rotated {
transform: rotate(-90deg);
}
.internal-filter-search {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 14px;
border-bottom: 1px solid var(--border);
background-color: var(--bg);
i {
color: var(--text2);
flex-shrink: 0;
}
input {
background: none;
border: none;
outline: none;
color: var(--text);
font-family: var(--typography);
font-size: 0.85rem;
width: 100%;
&::placeholder {
color: var(--text2);
}
}
}
.filter-title-left {
display: flex;
align-items: center;
gap: 7px;
}
.filter-title-right {
display: flex;
align-items: center;
gap: 6px;
}
.internal-filter-count {
display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--rhpz-orange);
color: #111;
font-size: 0.7rem;
font-weight: 700;
min-width: 18px;
height: 18px;
padding: 0 5px;
line-height: 1;
}
.internal-filter-clear {
background: none;
border: 1px solid var(--border);
color: var(--text2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
transition: all 0.15s;
&:hover {
border-color: var(--error);
color: var(--error);
}
}
.filter-search-clear {
background: none;
border: none;
color: var(--text2);
cursor: pointer;
display: flex;
align-items: center;
padding: 0;
flex-shrink: 0;
transition: color 0.15s;
&:hover {
color: var(--text);
}
}

View File

@@ -27,3 +27,11 @@
grid-template-columns: 0.5fr 1fr 0.25fr;
gap: 20px;
}
.grid-entries {
display: grid;
grid-template-columns: repeat(6,1fr);
gap: 20px;
margin-bottom: 20px;
}

View File

@@ -0,0 +1,119 @@
.hovercard-overlay {
position: absolute;
z-index: 2000;
background-color: var(--bg2);
border: 1px solid var(--border);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
.hovercard-overlay-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 30px;
color: var(--text2);
}
.hovercard-overlay-error {
padding: 20px;
text-align: center;
color: var(--text2);
font-size: 0.85rem;
}
.hovercard {
width: 280px;
}
.hovercard-header {
height: 70px;
background-color: var(--bg3);
border-bottom: 1px solid var(--border);
position: relative;
}
.hovercard-avatar {
position: absolute;
bottom: -26px;
left: 16px;
width: 52px;
height: 52px;
border-radius: 50%;
border: 3px solid var(--bg2);
overflow: hidden;
background-color: var(--bg4);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 1.2rem;
color: var(--text);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.hovercard-body {
padding: 34px 16px 16px;
}
.hovercard-username {
font-weight: 600;
font-size: 1rem;
color: var(--text);
margin-bottom: 2px;
}
.hovercard-title {
font-size: 0.8rem;
color: var(--rhpz-orange);
margin-bottom: 14px;
min-height: 14px;
}
.hovercard-stats {
display: flex;
border: 1px solid var(--border);
margin-bottom: 14px;
}
.hovercard-stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 4px;
border-right: 1px solid var(--border);
&:last-child {
border-right: none;
}
.stat-value {
font-size: 0.95rem;
font-weight: 600;
color: var(--text);
}
.stat-label {
font-size: 0.68rem;
color: var(--text2);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 2px;
}
}
.hovercard-actions {
display: flex;
gap: 8px;
}
.hovercard-actions .btn {
flex: 1;
justify-content: center;
font-size: 0.82rem;
}

View File

@@ -0,0 +1,138 @@
.notifications {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 340px;
max-height: 480px;
overflow-y: auto;
background-color: var(--bg2);
border: 1px solid var(--border);
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
z-index: 2000;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-thumb {
background-color: var(--border);
}
&::-webkit-scrollbar-track {
background-color: var(--bg2);
}
}
@keyframes dropdown-enter {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.dropdown-enter {
animation: dropdown-enter 0.15s ease;
}
.notifications-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background-color: var(--bg3);
z-index: 1;
.notifications-header-title {
font-weight: 600;
font-size: 0.9rem;
color: var(--text);
}
.notifications-header-actions {
display: flex;
gap: 6px;
}
}
.notifications-loading, .notifications-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 40px 20px;
color: var(--text2);
font-size: 0.85rem;
}
.notifications-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
text-decoration: none;
color: var(--text);
transition: background-color 0.1s;
position: relative;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: var(--bg3);
}
.unread {
border-left: 2px solid var(--rhpz-orange);
background-color: var(--bg3);
}
}
.notifications-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
background-color: var(--bg4);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.9rem;
color: var(--text);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.notifications-content {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
.notifications-text {
font-size: 0.88rem;
color: var(--text);
line-height: 1.4;
}
.notifications-date {
font-size: 0.75rem;
color: var(--text2);
}
}
.notifications-unread-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--rhpz-orange);
flex-shrink: 0;
margin-top: 4px;
}

View File

@@ -41,7 +41,15 @@
}
}
.topbar-actions {
display: flex;
gap: 8px;
}
.vertical-separator {
align-items: center;
border-left: 1px solid var(--border);
}
}

View File

@@ -0,0 +1,4 @@
.xf-menu-user-avatar-fix {
width: 40px !important;
height: 40px !important;
}

View File

@@ -3,6 +3,8 @@ import EasyMDE from "easymde";
import "easymde/dist/easymde.min.css";
import { calculate as calculateHashes } from "./hashes.js";
import hovercard from "./hovercard.js";
import notifications from "./notifications.js";
// Lucide icons.
@@ -19,3 +21,9 @@ window.EasyMDE = EasyMDE;
// Hashes.
window.calculateHashes = calculateHashes;
// Hover card.
Alpine.store('hovercard', hovercard() );
// Notifications
Alpine.store('notifications', notifications() );

115
resources/js/hovercard.js Normal file
View File

@@ -0,0 +1,115 @@
/** @typedef { import('types/HovercardResponse.js').HovercardResponse} HovercardResponse */
export default function hovercard(){
return {
/**
* @type {boolean}
*/
start: false,
/**
* @type {HovercardResponse}
*/
data: null,
/**
* @type {boolean}
*/
loading: false,
/**
* @type {any}
*/
error: false,
/**
* @type {HTMLElement|null}
*/
anchorEl: null,
/**
* @type {number}
*/
x: 0,
/**
* @type {number}
*/
y: 0,
/**
*
* @param {HTMLElement} anchorEl
* @param {string} fetchUrl
* @return {Promise<void>}
*/
async open(anchorEl, fetchUrl){
if( this.start && this.anchorEl === anchorEl ){
// this.close();
return;
}
this.start = true;
this.anchorEl = anchorEl;
this.data = null;
this.loading = true;
this.error = false;
this.updatePosition(anchorEl);
try {
const RESPONSE = await fetch(fetchUrl);
if( !RESPONSE.ok )
throw new Error(RESPONSE.status);
let json = await RESPONSE.json();
if( !json.user )
throw new Error(RESPONSE.status);
this.data = json.user;
Alpine.nextTick(() => {
const card = document.querySelector('.hovercard');
if (card) window.refreshIcons(card);
});
} catch( error ){
this.error = true;
} finally {
this.loading = false;
}
},
/**
* Update Hovercard position
* @param {HTMLElement} anchorEl
*/
updatePosition(anchorEl){
const RECT = anchorEl.getBoundingClientRect();
const SCROLL_X = window.scrollX;
const SCROLL_Y = window.scrollY;
let x = RECT.left + SCROLL_X;
let y = RECT.bottom + SCROLL_Y + 8;
const WIDTH = 280;
if( x + WIDTH > window.innerWidth ){
x = window.innerWidth - WIDTH - 16;
}
this.x = x;
this.y = y;
},
/**
* Close the hovercard.
*/
close(){
this.start = false;
this.data = null;
this.anchorEl = null;
this.error = false;
}
}
}

View File

@@ -0,0 +1,126 @@
/** @typedef { import('types/AlertsResponseItem.js').AlertsResponseItem} AlertsResponseItem */
export default function notifications() {
return {
/**
* @type {boolean}
*/
start: false,
/**
* @type {AlertsResponseItem[]}
*/
data: null,
/**
* @type {boolean}
*/
loading: false,
/**
* @type {boolean}
*/
error: false,
/**
* @type {number}
*/
unviewed: 0,
/**
* Request for getting notifications.
*
* @return {Promise<void>}
*/
async getNotifications() {
if( this.loading )
return;
this.loading = true;
this.error = false;
try {
const RESPONSE = await fetch('/api/dynamic/notifications', { credentials: "include", headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if( !RESPONSE.ok )
throw new Error(RESPONSE.status)
let json = await RESPONSE.json()
if( !json.alerts )
throw new Error(RESPONSE.status);
this.data = json.alerts;
} catch (error) {
this.error = true;
} finally {
this.loading = false;
}
},
/**
*
* @param {HTMLElement} anchorEl
* @return {Promise<void>}
*/
async open( anchorEl ){
if( this.start ){
this.close();
return;
}
this.start = !this.start;
if( this.start && !this.data ){
await this.getNotifications();
}
if( this.start ){
Alpine.nextTick(() => window.refreshIcons(document.querySelector('.notifications')))
}
},
/**
*
* @return {Promise<void>}
*/
async markAllRead(){
await fetch('/api/dynamic/notifications/mark-all-read', {
method: 'POST',
credentials: "include",
headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '' }
});
if(this.data && this.data.length > 0){
this.data = this.data.map(a => ({
...a,
view_date: Math.floor(Date.now() / 1000)
}));
}
},
/**
* @return {number}
*/
get unread(){
if( !this.data ){
return this.unviewed;
}
return this.data.filter(a => a.view_date === 0 ).length;
},
/**
*
*/
close(){
if( this.start && this.unread > 0)
this.markAllRead();
this.start = false;
}
}
}

View File

@@ -0,0 +1,21 @@
/**
* @typedef {Object} AlertsResponseItem
*
* @see app/Http/DynamicLoadController.php
*
* @property {number} alert_id
* @property {number} alerted_user_id
* @property {number} user_id
* @property {string} username
* @property {string} content_type
* @property {number} content_id
* @property {string} action
* @property {number} event_date
* @property {number} view_date
* @property {number} read_date
* @property {boolean} auto_read
* @property {string} alert_text
* @property {string} alert_url
* @property {Object} user See XenForo API Documentation for more details.
*/
export {}

View File

@@ -0,0 +1,18 @@
/**
* @typedef {Object} HovercardResponse
*
* @see app/Http/DynamicLoadController.php
*
* @property {string} username
* @property {string|null} avatar_url
* @property {string} avatar_color
* @property {string} avatar_letter
* @property {string} group_name
* @property {string} joined
* @property {string} last_seen
* @property {number} message_count
* @property {number} reaction_score
* @property {number} trophy_points
* @property {number} entries_count
*/
export {}

View File

@@ -0,0 +1,32 @@
<div class="filter-group" x-data="{open:false,search:''}">
<div class="filter-title-row" @click="open = !open">
<div class="filter-title-left">
<h4 class="filter-title">{{ $title }}</h4>
<span class="internal-filter-count" x-show="$wire.{{ $model }}.length > 0" x-text="$wire.{{ $model }}.length"></span>
</div>
<div class="filter-title-right" @click.stop>
<div class="filter-mode">
<button type="button" wire:click="$set('{{ $modeModel }}', 'or')" class="filter-btn-mode {{ $selectedMode === 'or' ? 'active' : '' }}">OR</button>
<button type="button" wire:click="$set('{{ $modeModel }}', 'and')" class="filter-btn-mode {{ $selectedMode === 'and' ? 'active' : '' }}">AND</button>
</div>
<button type="button" class="internal-filter-clear" x-show="$wire.{{ $model }}.length > 0" @click="$wire.set('{{ $model }}',[])" title="Clear">
<i data-lucide="x" size="11"></i>
</button>
<i data-lucide="chevron-down" size="14" class="filter-chevron" :class="{ 'rotated': !open }"></i>
</div>
</div>
<div x-show="open" x-transition>
<div class="internal-filter-search">
<i data-lucide="search" size="13"></i>
<input type="text" x-model="search" placeholder="Search...">
</div>
<div class="filter-options">
@foreach($items as $item)
<label class="filter-option" x-show="search.length >= 3 && '{{strtolower($item->{$nameProperty}) }}'.includes(search.toLowerCase())">
<input type="checkbox" wire:model.live="{{ $model }}" value="{{ $item->{$idProperty} }}">
{{ $item->{$nameProperty} }}
</label>
@endforeach
</div>
</div>
</div>

View File

@@ -0,0 +1,26 @@
<div class="filter-group" x-data="{open:false}">
<div class="filter-title-row" @click="open = !open">
<div class="filter-title-left">
<h4 class="filter-title">{{ $title }}</h4>
<span class="internal-filter-count" x-show="$wire.{{ $model }}.length > 0" x-text="$wire.{{ $model }}.length"></span>
</div>
<div class="filter-title-right" @click.stop>
<div class="filter-mode">
<button type="button" wire:click="$set('{{ $modeModel }}', 'or')" class="filter-btn-mode {{ $selectedMode === 'or' ? 'active' : '' }}">OR</button>
<button type="button" wire:click="$set('{{ $modeModel }}', 'and')" class="filter-btn-mode {{ $selectedMode === 'and' ? 'active' : '' }}">AND</button>
</div>
<button type="button" class="internal-filter-clear" x-show="$wire.{{ $model }}.length > 0" @click="$wire.set('{{ $model }}',[])" title="Clear">
<i data-lucide="x" size="11"></i>
</button>
<i data-lucide="chevron-down" size="14" class="filter-chevron" :class="{ 'rotated': !open }"></i>
</div>
</div>
<div class="filter-options" x-show="open" x-transition>
@foreach($items as $item)
<label class="filter-option">
<input type="checkbox" wire:model.live="{{ $model }}" value="{{ $item->{$idProperty} }}">
{{ $item->{$nameProperty} }}
</label>
@endforeach
</div>
</div>

View File

@@ -0,0 +1,28 @@
<div class="filter-group" x-data="{open: false,search:''}">
<div class="filter-title-row" @click="open = !open">
<div class="filter-title-left">
<h4 class="filter-title">{{ $title }}</h4>
<span class="internal-filter-count" x-show="$wire.{{ $model }}.length > 0" x-text="$wire.{{ $model }}.length"></span>
</div>
<div class="filter-title-right" @click.stop>
<button type="button" class="internal-filter-clear" x-show="$wire.{{ $model }}.length > 0" @click="$wire.set('{{ $model }}',[])" title="Clear">
<i data-lucide="x" size="11"></i>
</button>
<i data-lucide="chevron-down" size="14" class="filter-chevron" :class="{ 'rotated': !open }"></i>
</div>
</div>
<div x-show="open" x-transition>
<div class="internal-filter-search">
<i data-lucide="search" size="13"></i>
<input type="text" x-model="search" placeholder="Search...">
</div>
<div class="filter-options">
@foreach($items as $item)
<label class="filter-option" x-show="search.length >= 3 && '{{strtolower($item->{$nameProperty}) }}'.includes(search.toLowerCase())">
<input type="checkbox" wire:model.live="{{ $model }}" value="{{ $item->{$idProperty} }}">
{{ $item->{$nameProperty} }}
</label>
@endforeach
</div>
</div>
</div>

View File

@@ -0,0 +1,22 @@
<div class="filter-group" x-data="{open: false}">
<div class="filter-title-row" @click="open = !open">
<div class="filter-title-left">
<h4 class="filter-title">{{ $title }}</h4>
<span class="internal-filter-count" x-show="$wire.{{ $model }}.length > 0" x-text="$wire.{{ $model }}.length"></span>
</div>
<div class="filter-title-right" @click.stop>
<button type="button" class="internal-filter-clear" x-show="$wire.{{ $model }}.length > 0" @click="$wire.set('{{ $model }}',[])" title="Clear">
<i data-lucide="x" size="11"></i>
</button>
<i data-lucide="chevron-down" size="14" class="filter-chevron" :class="{ 'rotated': !open }"></i>
</div>
</div>
<div class="filter-options" x-show="open" x-transition>
@foreach($items as $item)
<label class="filter-option">
<input type="checkbox" wire:model.live="{{ $model }}" value="{{ $item->{$idProperty} }}">
{{ $item->{$nameProperty} }}
</label>
@endforeach
</div>
</div>

View File

@@ -0,0 +1,40 @@
<div class="entry-card">
<div class="entry-cover-wrapper">
<span class="entry-badge">{{ $entry->getRealPlatform()?->name ?? 'Unknown' }}</span>
@if( $entry->main_image )
<img src="{{ Storage::url($entry->main_image) }}">
@else
<i data-lucide="image" size="40" color="var(--border)"></i>
@endif
</div>
<div class="entry-card-info">
<div class="entry-card-title">{{ $entry->title }}</div>
<div class="entry-card-author">
@forelse( $entry->authors as $author)
@if($loop->first)By @endif
{{ $author-> name }}
@if( !$loop->last ), @endif
@empty
No authors
@endforelse
</div>
<div style="margin-bottom:10px">
<span class="badge {{ $entry->type }}">{{ \App\View\Components\EntryCard::ENTRY_TYPES_BADGE[$entry->type] ?? $entry->type }}</span>
@if( section_must_be('romhacks', $entry->type ) )
@foreach( $entry->modifications as $modif )
<span class="badge orange">{{ $modif->name }}</span>
@endforeach
@if( $entry->status_id )
<span class="badge">{{ $entry->status->name }}</span>
@endif
@foreach( $entry->languages as $lang )
<span class="badge">{{ $lang->name }}</span>
@endforeach
@endif
</div>
<div class="entry-card-meta">
<span><i data-lucide="download" size="12"></i> x</span>
<span>Added: {{ $entry->created_at->format('y-m-d') }}</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,76 @@
<div
x-data
x-show="$store.hovercard.start"
x-cloak
class="hovercard-overlay hovercard"
:style="{ left: $store.hovercard.x + 'px', top: $store.hovercard.y + 'px' }"
@mouseleave="$store.hovercard.close()"
@keydown.escape.window="$store.hovercard.close()"
>
<template x-if="$store.hovercard.loading">
<div class="hovercard-overlay-loading">
<i data-lucide="loader-2" class="spin"></i>
</div>
</template>
<template x-if="$store.hovercard.error">
<div class="hovercard-overlay-error">
<i data-lucide="alert-circle"></i>
<p>Failed to load profile.</p>
</div>
</template>
<template x-if="$store.hovercard.data && !$store.hovercard.loading && !$store.hovercard.error">
<div>
<div class="hovercard-header">
<div
class="hovercard-avatar"
:style="$store.hovercard.data.avatar_url === null
? { background: $store.hovercard.data.avatar_color }
: {}"
>
<template x-if="$store.hovercard.data.avatar_url !== null">
<img :src="$store.hovercard.data.avatar_url" alt="avatar">
</template>
<template x-if="$store.hovercard.data.avatar_url === null">
<span x-text="$store.hovercard.data.avatar_letter"></span>
</template>
</div>
</div>
<div class="hovercard-body">
<div class="hovercard-username" x-text="$store.hovercard.data.username"></div>
<div class="hovercard-title" x-text="$store.hovercard.data.group_name"></div>
<div class="hovercard-stats">
<div class="hovercard-stat">
<span class="stat-value" x-text="$store.hovercard.data.message_count"></span>
<span class="stat-label">Messages</span>
</div>
<div class="hovercard-stat">
<span class="stat-value" x-text="$store.hovercard.data.reaction_score"></span>
<span class="stat-label">Reactions</span>
</div>
<div class="hovercard-stat">
<span class="stat-value" x-text="$store.hovercard.data.trophy_points"></span>
<span class="stat-label">Points</span>
</div>
<div class="hovercard-stat">
<span class="stat-value" x-text="$store.hovercard.data.entries_count"></span>
<span class="stat-label">Entries</span>
</div>
</div>
<div class="hovercard-actions">
<a href="#" class="btn" title="View profile">
<i data-lucide="user" size="14"></i>
</a>
<a href="#" class="btn" title="Send message">
<i data-lucide="mail" size="14"></i>
</a>
</div>
</div>
</div>
</template>
</div>

View File

@@ -34,7 +34,9 @@
{{ \Auth::user()?->username ?? "Guest" }}
</span>
<span class="user_role">
Lorem
<a href="{{ \Auth::guest() ? xfRoute('login') : xfRoute('logout') }}">
{{ \Auth::guest() ? 'Login' : 'Logout' }}
</a>
</span>
</div>
</div>

View File

@@ -0,0 +1,68 @@
<div
x-data
x-cloak
x-show="$store.notifications.start"
x-translation:enter="dropdown-enter"
x-transition:leave="dropdown-leave"
class="notifications"
@click.outside="$store.notifications.close()"
@keydown.escape.window="$store.notifications.close()"
>
<div class="notifications-header">
<span class="notifications-header-title">Notifications</span>
<div class="notifications-header-actions">
<button type="button" class="btn" x-show="$store.notifications.unread > 0" @click="$store.notifications.markAllRead()" title="Mark all as read">
<i data-lucide="check-circle" size="14"></i>
</button>
<a href="{{ xfRoute('account.alerts') }}" class="btn">
<i data-lucide="external-link" size="14"></i>
</a>
</div>
</div>
<template x-if="$store.notifications.loading">
<div class="notifications-loading">
<i data-lucide="loader-2" class="spin"></i>
</div>
</template>
<template x-if="$store.notifications.error">
<div class="notifications-empty">
<i data-lucide="alert-circle" size="24"></i>
<span>Failed to load notifications.</span>
</div>
</template>
<template x-if="$store.notifications.data && !$store.notifications.loading">
<div>
<template x-if="$store.notifications.data.length === 0">
<div class="notifications-empty">
<i data-lucide="bell-off" size="24"></i>
<span>No new notifications.</span>
</div>
</template>
<template x-for="notif in $store.notifications.data" :key="notif.alert_id">
<a :href="notif.alert_url" class="notifications-item" :class="{ 'unread': notif.view_date === 0 }">
<div class="notifications-avatar">
<template x-if="notif.User?.avatar_urls?.s">
<img :src="notif.User.avatar_urls.s" :alt="notif.username">
</template>
<template x-if="!notif.User?.avatar_urls?.s">
<span x-text="notif.username?.charAt(0).toUpperCase()"></span>
</template>
</div>
<div class="notifications-content">
<span class="notifications-text" x-text="notif.alert_text"></span>
<span class="notifications-date" x-text="new Date(notif.event_date * 1000).toLocaleDateString()"></span>
</div>
<div class="notifications-unread-dot" x-show="notif.view_date === 0"></div>
</a>
</template>
</div>
</template>
</div>

View File

@@ -1,3 +1,4 @@
@php $topbarModSeparator = false; $topBarAdminSeparator = false; @endphp
<header id="topbar">
<button class="mobile-toggle">
<i data-lucide="menu"></i>
@@ -9,8 +10,65 @@
</div>
<div class="topbar-actions">
@if( !\Auth::guest() && \Auth::user()->is_admin === 1 )
@php $topbarAdminSeparator = true; @endphp
<a href="{{ config('app.forum_url') . '/admin.php' }}" class="btn">
<i data-lucide="landmark" size="18"></i>
</a>
<a href="{{ config('app.url') . '/manage' }}" class="btn">
<i data-lucide="shield-cog" size="18"></i>
</a>
@endif
@if( $topbarAdminSeparator )
<div class="vertical-separator"></div>
@endif
@if( !\Auth::guest() && \Auth::user()->is_moderator === 1 )
@php $topbarModSeparator = true; @endphp
<a href="#" class="btn">
<i data-lucide="siren" size="18"></i>
</a>
<a href="{{ xfRoute('approval-queue') }}" class="btn">
<i data-lucide="message-circle-check" size="18"></i>
</a>
<a href="{{ xfRoute('reports') }}" class="btn">
<i data-lucide="triangle-alert" size="18"></i>
</a>
@endif
@if( $topbarModSeparator )
<div class="vertical-separator"></div>
@endif
{{-- Users --}}
@if( !\Auth::guest() && \Auth::user()->can('romhackplaza', 'canSubmitEntry') )
<a href="#" class="btn">
<i data-lucide="hard-drive-upload" size="18"></i>
</a>
@endif
@if( !\Auth::guest() )
<div x-data x-init="$store.notifications.unviewed = {{ \Auth::user()->alerts_unviewed }}" style="position:relative">
<button type="button" class="btn" :class="{ 'active': $store.notifications.start }" @click="$store.notifications.open($el)" @click.outside="$store.notifications.close()">
<i data-lucide="bell" size="18"></i>
<span
class="topbar-badge"
:class="$store.notifications.unread > 9 ? 'topbar-badge--overflow' : ''"
x-show="$store.notifications.unread > 0"
x-text="$store.notifications.unread > 99 ? '99+' : $store.notifications.unread"
></span>
</button>
@include('components.notifications')
</div>
<button class="btn">
<i data-lucide="mail" size="18"></i>
</button>
@endif
<button class="btn">
<i data-lucide="bell" size="18"></i>
<i data-lucide="settings" size="18"></i>
</button>
</div>
</header>

View File

@@ -0,0 +1,6 @@
<span x-data class="userlink"
@mouseenter.debounce.300ms="$store.hovercard.open($el,'{{ route('dynamic.hovercard', ['user_id' => $user?->user_id ?? 0 ]) }}')"
@mouseleave="setTimeout(() => { const C = document.querySelector('.hovercard'); if(!C?.matches(':hover')) $store.hovercard.close(); }, 200)"
>
{{ $user->username }}
</span>

View File

@@ -1,11 +1,7 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>RomHack Plaza</title>
</head>
<body>
<h1>Bienvenue sur RomHack Plaza</h1>
<p>Le catalogue est en construction.</p>
</body>
</html>
@extends('layouts.app')
@section('page-title', "Database - " . config('app.name') )
@section('content')
@livewire('database')
@endsection

View File

@@ -8,5 +8,5 @@
</div>
<x-error-block error-type="page-not-allowed" />
{{ xfRoute( 'profile-posts.comments', ['profile_post_comment_id' => 1] ) }}
<x-xf-username-link user-id="2" />
@endsection

View File

@@ -28,6 +28,7 @@
</main>
</div>
@include('components.hovercard')
@livewireScripts
@stack('scripts')
</body>

View File

@@ -0,0 +1,79 @@
<div @filters-updated.window="refreshIcons($el)">
<div class="database-search filter-bar">
<input
type="text"
wire:model.live.debounce="search"
placeholder="Search..."
class="form-input filter-bar-search"
>
@if( $search || $types || $platforms || $games || $statuses || $authors || $languages || $modifications )
<button type="button" wire:click="clearFilters" class="btn">
<i data-lucide="x"></i> Clear filters
</button>
@endif
</div>
<div class="database-wrapper">
<aside class="database-filters">
{{-- Types --}}
<div class="filter-group" x-data="{open: true}">
<div class="filter-title-row" @click="open = !open">
<h4 class="filter-title">Type</h4>
<i data-lucide="chevron-down" size="14" class="filter-chevron" :class="{ 'rotated': !open }"></i>
</div>
<div class="filter-options" x-show="open" x-transition>
@foreach( \App\Livewire\Database::ENTRY_TYPES as $k => $v )
<label class="filter-option">
<input type="checkbox" wire:model.live="types" value="{{ $k }}">
{{ $v }}
</label>
@endforeach
</div>
</div>
{{-- Games --}}
<x-database-filter-without-mode-search title="Game" :items="$allGames" model="games" />
{{-- Platforms --}}
<x-database-filter-without-mode title="Platform" :items="$allPlatforms" model="platforms"/>
{{-- Statuses --}}
<x-database-filter-without-mode title="Status" :items="$allStatuses" model="statuses"/>
{{-- Authors --}}
<x-database-filter-with-mode-search title="Authors" :items="$allAuthors" model="authors" mode-model="authorsMode" :selected-mode="$authorsMode" />
{{-- Languages --}}
<x-database-filter-with-mode title="Languages" :items="$allLanguages" model="languages" mode-model="languagesMode" :selected-mode="$languagesMode" />
{{-- Modifications --}}
<x-database-filter-with-mode title="Modifications" :items="$allModifications" model="modifications" mode-model="modificationsMode" :selected-mode="$modificationsMode" />
</aside>
<div class="database-results">
<div class="database-sort">
@foreach( \App\Livewire\Database::SORT_OPTIONS as $k => $v )
<button type="button" wire:click="setSort('{{ $k }}')" class="btn {{ $sortBy === $k ? 'active' : '' }}">
{{ $v }}
</button>
@if( $sortBy === $k )
<i data-lucide="{{ $sortDir === 'asc' ? 'arrow-up' : 'arrow-down' }}"></i>
@endif
@endforeach
<span class="database-results-count">{{ $entries->total() }} results</span>
</div>
<div class="grid-entries">
@forelse($entries as $entry)
<x-entry-card :entry="$entry" />
@empty
<p>No entries found.</p>
@endforelse
</div>
{{ $entries->links() }}
</div>
</div>
</div>

View File

@@ -0,0 +1,102 @@
@php
if (! isset($scrollTo)) {
$scrollTo = 'body';
}
$scrollIntoViewJsSnippet = ($scrollTo !== false)
? <<<JS
(\$el.closest('{$scrollTo}') || document.querySelector('{$scrollTo}')).scrollIntoView()
JS
: '';
@endphp
<div>
@if ($paginator->hasPages())
<nav class="d-flex justify-items-center justify-content-between">
<div class="d-flex justify-content-between flex-fill d-sm-none">
<ul class="pagination">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<li class="page-item disabled" aria-disabled="true">
<span class="page-link">@lang('pagination.previous')</span>
</li>
@else
<li class="page-item">
<button type="button" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="page-link" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled">@lang('pagination.previous')</button>
</li>
@endif
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<li class="page-item">
<button type="button" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="page-link" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled">@lang('pagination.next')</button>
</li>
@else
<li class="page-item disabled" aria-disabled="true">
<span class="page-link" aria-hidden="true">@lang('pagination.next')</span>
</li>
@endif
</ul>
</div>
<div class="d-none flex-sm-fill d-sm-flex align-items-sm-center justify-content-sm-between">
<div>
<p class="small text-muted">
{!! __('Showing') !!}
<span class="fw-semibold">{{ $paginator->firstItem() }}</span>
{!! __('to') !!}
<span class="fw-semibold">{{ $paginator->lastItem() }}</span>
{!! __('of') !!}
<span class="fw-semibold">{{ $paginator->total() }}</span>
{!! __('results') !!}
</p>
</div>
<div>
<ul class="pagination">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<li class="page-item disabled" aria-disabled="true" aria-label="@lang('pagination.previous')">
<span class="page-link" aria-hidden="true">&lsaquo;</span>
</li>
@else
<li class="page-item">
<button type="button" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="page-link" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" aria-label="@lang('pagination.previous')">&lsaquo;</button>
</li>
@endif
{{-- Pagination Elements --}}
@foreach ($elements as $element)
{{-- "Three Dots" Separator --}}
@if (is_string($element))
<li class="page-item disabled" aria-disabled="true"><span class="page-link">{{ $element }}</span></li>
@endif
{{-- Array Of Links --}}
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<li class="page-item active" wire:key="paginator-{{ $paginator->getPageName() }}-page-{{ $page }}" aria-current="page"><span class="page-link">{{ $page }}</span></li>
@else
<li class="page-item" wire:key="paginator-{{ $paginator->getPageName() }}-page-{{ $page }}"><button type="button" class="page-link" wire:click="gotoPage({{ $page }}, '{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}">{{ $page }}</button></li>
@endif
@endforeach
@endif
@endforeach
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<li class="page-item">
<button type="button" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="page-link" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" aria-label="@lang('pagination.next')">&rsaquo;</button>
</li>
@else
<li class="page-item disabled" aria-disabled="true" aria-label="@lang('pagination.next')">
<span class="page-link" aria-hidden="true">&rsaquo;</span>
</li>
@endif
</ul>
</div>
</div>
</nav>
@endif
</div>

View File

@@ -0,0 +1,53 @@
@php
if (! isset($scrollTo)) {
$scrollTo = 'body';
}
$scrollIntoViewJsSnippet = ($scrollTo !== false)
? <<<JS
(\$el.closest('{$scrollTo}') || document.querySelector('{$scrollTo}')).scrollIntoView()
JS
: '';
@endphp
<div>
@if ($paginator->hasPages())
<nav>
<ul class="pagination">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<li class="page-item disabled" aria-disabled="true">
<span class="page-link">@lang('pagination.previous')</span>
</li>
@else
@if(method_exists($paginator,'getCursorName'))
<li class="page-item">
<button dusk="previousPage" type="button" class="page-link" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $paginator->previousCursor()->encode() }}" wire:click="setPage('{{$paginator->previousCursor()->encode()}}','{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled">@lang('pagination.previous')</button>
</li>
@else
<li class="page-item">
<button type="button" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="page-link" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled">@lang('pagination.previous')</button>
</li>
@endif
@endif
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
@if(method_exists($paginator,'getCursorName'))
<li class="page-item">
<button dusk="nextPage" type="button" class="page-link" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $paginator->nextCursor()->encode() }}" wire:click="setPage('{{$paginator->nextCursor()->encode()}}','{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled">@lang('pagination.next')</button>
</li>
@else
<li class="page-item">
<button type="button" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="page-link" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled">@lang('pagination.next')</button>
</li>
@endif
@else
<li class="page-item disabled" aria-disabled="true">
<span class="page-link">@lang('pagination.next')</span>
</li>
@endif
</ul>
</nav>
@endif
</div>

View File

@@ -0,0 +1,56 @@
@php
if (! isset($scrollTo)) {
$scrollTo = 'body';
}
$scrollIntoViewJsSnippet = ($scrollTo !== false)
? <<<JS
(\$el.closest('{$scrollTo}') || document.querySelector('{$scrollTo}')).scrollIntoView()
JS
: '';
@endphp
<div>
@if ($paginator->hasPages())
<nav role="navigation" aria-label="Pagination Navigation" class="flex justify-between">
<span>
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md dark:text-gray-600 dark:bg-gray-800 dark:border-gray-600">
{!! __('pagination.previous') !!}
</span>
@else
@if(method_exists($paginator,'getCursorName'))
<button type="button" dusk="previousPage" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $paginator->previousCursor()->encode() }}" wire:click="setPage('{{$paginator->previousCursor()->encode()}}','{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-blue-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:focus:border-blue-700 dark:active:bg-gray-700 dark:active:text-gray-300">
{!! __('pagination.previous') !!}
</button>
@else
<button
type="button" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-blue-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:focus:border-blue-700 dark:active:bg-gray-700 dark:active:text-gray-300">
{!! __('pagination.previous') !!}
</button>
@endif
@endif
</span>
<span>
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
@if(method_exists($paginator,'getCursorName'))
<button type="button" dusk="nextPage" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $paginator->nextCursor()->encode() }}" wire:click="setPage('{{$paginator->nextCursor()->encode()}}','{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-blue-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:focus:border-blue-700 dark:active:bg-gray-700 dark:active:text-gray-300">
{!! __('pagination.next') !!}
</button>
@else
<button type="button" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-blue-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:focus:border-blue-700 dark:active:bg-gray-700 dark:active:text-gray-300">
{!! __('pagination.next') !!}
</button>
@endif
@else
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md dark:text-gray-600 dark:bg-gray-800 dark:border-gray-600">
{!! __('pagination.next') !!}
</span>
@endif
</span>
</nav>
@endif
</div>

View File

@@ -0,0 +1,35 @@
@if ($paginator->hasPages())
<div class="database-pagination">
{{-- Précédent --}}
@if ($paginator->onFirstPage())
<button class="btn" disabled>«</button>
@else
<button class="btn" wire:click="previousPage">«</button>
@endif
{{-- Pages --}}
@foreach ($elements as $element)
@if (is_string($element))
<button class="btn" disabled>{{ $element }}</button>
@endif
@if (is_array($element))
@foreach ($element as $page => $url)
<button
class="btn {{ $page == $paginator->currentPage() ? 'active' : '' }}"
wire:click="gotoPage({{ $page }})"
>{{ $page }}</button>
@endforeach
@endif
@endforeach
{{-- Suivant --}}
@if ($paginator->hasMorePages())
<button class="btn" wire:click="nextPage">»</button>
@else
<button class="btn" disabled>»</button>
@endif
</div>
@endif