feat: add tag discovery analytics and reporting
This commit is contained in:
@@ -154,6 +154,8 @@ export default function BulkTagModal({ open, mode = 'add', onClose, onConfirm })
|
||||
{!loading &&
|
||||
results.map((tag) => {
|
||||
const isSelected = selected.some((t) => t.id === tag.id)
|
||||
const recentClicks = Number(tag.recent_clicks || 0)
|
||||
const usageCount = Number(tag.usage_count || 0)
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
@@ -174,7 +176,9 @@ export default function BulkTagModal({ open, mode = 'add', onClose, onConfirm })
|
||||
/>
|
||||
{tag.name}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{tag.usage_count?.toLocaleString() ?? 0} uses</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{recentClicks > 0 ? `${recentClicks.toLocaleString()} recent clicks` : `${usageCount.toLocaleString()} uses`}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
63
resources/js/components/Studio/BulkTagModal.test.jsx
Normal file
63
resources/js/components/Studio/BulkTagModal.test.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import BulkTagModal from './BulkTagModal'
|
||||
|
||||
describe('BulkTagModal', () => {
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = '<meta name="csrf-token" content="test-token">'
|
||||
|
||||
global.fetch = vi.fn(async (url) => {
|
||||
const requestUrl = String(url)
|
||||
|
||||
if (requestUrl.includes('?q=high')) {
|
||||
return {
|
||||
json: async () => ([
|
||||
{ id: 2, name: 'High Contrast', slug: 'high-contrast', usage_count: 120, recent_clicks: 18 },
|
||||
{ id: 3, name: 'High Detail', slug: 'high-detail', usage_count: 90, recent_clicks: 0 },
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
json: async () => ([
|
||||
{ id: 1, name: 'Popular Pick', slug: 'popular-pick', usage_count: 300, recent_clicks: 9 },
|
||||
]),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.head.innerHTML = ''
|
||||
})
|
||||
|
||||
it('shows recent click momentum for initial results', async () => {
|
||||
render(<BulkTagModal open mode="add" onClose={() => {}} onConfirm={() => {}} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Popular Pick')).not.toBeNull()
|
||||
})
|
||||
|
||||
expect(screen.getByText('9 recent clicks')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('returns selected tag ids and shows recent click momentum in search results', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
|
||||
render(<BulkTagModal open mode="add" onClose={() => {}} onConfirm={onConfirm} />)
|
||||
|
||||
const input = screen.getByPlaceholderText('Search tags…')
|
||||
await userEvent.type(input, 'high')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('18 recent clicks')).not.toBeNull()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /High Contrast/i }))
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add 1 tag/i }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith([2])
|
||||
})
|
||||
})
|
||||
@@ -52,7 +52,8 @@ function toSuggestionItems(raw) {
|
||||
key: item?.id ?? tag,
|
||||
label: item?.name || item?.tag || item?.slug || tag,
|
||||
tag,
|
||||
usageCount: typeof item?.usage_count === 'number' ? item.usage_count : null,
|
||||
usageCount: Number.isFinite(Number(item?.usage_count)) ? Number(item.usage_count) : null,
|
||||
recentClicks: Number.isFinite(Number(item?.recent_clicks)) ? Number(item.recent_clicks) : 0,
|
||||
isAi: Boolean(item?.is_ai || item?.source === 'ai'),
|
||||
}
|
||||
})
|
||||
@@ -173,6 +174,9 @@ function SuggestionDropdown({
|
||||
|
||||
{!loading && !error && suggestions.map((item, index) => {
|
||||
const active = highlightedIndex === index
|
||||
const detailLabel = item.recentClicks > 0
|
||||
? `${item.recentClicks.toLocaleString()} recent`
|
||||
: (typeof item.usageCount === 'number' ? `${item.usageCount.toLocaleString()} uses` : null)
|
||||
return (
|
||||
<li
|
||||
key={item.key}
|
||||
@@ -192,8 +196,8 @@ function SuggestionDropdown({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{typeof item.usageCount === 'number' && (
|
||||
<span className="shrink-0 text-[11px] text-white/50">{item.usageCount}</span>
|
||||
{detailLabel && (
|
||||
<span className="shrink-0 text-[11px] text-white/50">{detailLabel}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -26,8 +26,8 @@ describe('TagInput', () => {
|
||||
return {
|
||||
data: {
|
||||
data: [
|
||||
{ id: 1, name: 'cityscape', slug: 'cityscape', usage_count: 10 },
|
||||
{ id: 2, name: 'city', slug: 'city', usage_count: 30 },
|
||||
{ id: 1, name: 'cityscape', slug: 'cityscape', usage_count: 10, recent_clicks: 2 },
|
||||
{ id: 2, name: 'city', slug: 'city', usage_count: 30, recent_clicks: 14 },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -74,6 +74,17 @@ describe('TagInput', () => {
|
||||
expect(screen.getByRole('button', { name: /Remove tag/i })).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows recent click momentum when suggestions provide it', async () => {
|
||||
render(<Harness />)
|
||||
|
||||
const input = screen.getByLabelText('Tag input')
|
||||
await userEvent.type(input, 'city')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('14 recent')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('supports comma-separated paste', async () => {
|
||||
render(<Harness />)
|
||||
|
||||
|
||||
@@ -37,15 +37,18 @@ function toListItem(item) {
|
||||
if (!item) return null
|
||||
if (typeof item === 'string') {
|
||||
const slug = normalizeSlug(item)
|
||||
return slug ? { key: slug, slug, name: slug, usageCount: null, isAi: false } : null
|
||||
return slug ? { key: slug, slug, name: slug, usageCount: null, recentClicks: 0, isAi: false } : null
|
||||
}
|
||||
const slug = normalizeSlug(item.slug || item.tag || item.name || '')
|
||||
if (!slug) return null
|
||||
const usageCount = Number(item.usage_count)
|
||||
const recentClicks = Number(item.recent_clicks)
|
||||
return {
|
||||
key: String(item.id ?? slug),
|
||||
slug,
|
||||
name: item.name || item.tag || item.slug || slug,
|
||||
usageCount: typeof item.usage_count === 'number' ? item.usage_count : null,
|
||||
usageCount: Number.isFinite(usageCount) ? usageCount : null,
|
||||
recentClicks: Number.isFinite(recentClicks) ? recentClicks : 0,
|
||||
isAi: Boolean(item.is_ai || item.source === 'ai'),
|
||||
}
|
||||
}
|
||||
@@ -123,6 +126,10 @@ function AddNewRow({ label, onAdd, disabled }) {
|
||||
}
|
||||
|
||||
function ListRow({ item, isSelected, onToggle, disabled }) {
|
||||
const detailLabel = item.recentClicks > 0
|
||||
? `${item.recentClicks.toLocaleString()} recent clicks`
|
||||
: (typeof item.usageCount === 'number' ? `${item.usageCount.toLocaleString()} uses` : null)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -154,9 +161,9 @@ function ListRow({ item, isSelected, onToggle, disabled }) {
|
||||
)}
|
||||
</span>
|
||||
|
||||
{typeof item.usageCount === 'number' && (
|
||||
{detailLabel && (
|
||||
<span className="ml-3 shrink-0 text-[11px] text-white/40">
|
||||
{item.usageCount.toLocaleString()} uses
|
||||
{detailLabel}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
76
resources/js/components/tags/TagPicker.test.jsx
Normal file
76
resources/js/components/tags/TagPicker.test.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import TagPicker from './TagPicker'
|
||||
|
||||
function Harness({ initial = [] }) {
|
||||
const [tags, setTags] = React.useState(initial)
|
||||
|
||||
return (
|
||||
<TagPicker
|
||||
value={tags}
|
||||
onChange={setTags}
|
||||
suggestedTags={['sunset']}
|
||||
searchEndpoint="/api/tags/search"
|
||||
popularEndpoint="/api/tags/popular"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('TagPicker', () => {
|
||||
beforeEach(() => {
|
||||
window.axios = {
|
||||
get: vi.fn(async (url) => {
|
||||
if (url.startsWith('/api/tags/search')) {
|
||||
return {
|
||||
data: {
|
||||
data: [
|
||||
{ id: 2, name: 'High Contrast', slug: 'high-contrast', usage_count: 120, recent_clicks: 18 },
|
||||
{ id: 3, name: 'High Detail', slug: 'high-detail', usage_count: 90, recent_clicks: 0 },
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
data: [
|
||||
{ id: 1, name: 'Popular Pick', slug: 'popular-pick', usage_count: 300, recent_clicks: 9 },
|
||||
],
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('shows recent click momentum for popular tags on mount', async () => {
|
||||
render(<Harness />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Popular Pick')).not.toBeNull()
|
||||
})
|
||||
|
||||
expect(screen.getByText('9 recent clicks')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows recent click momentum in search results and lets the user select a tag', async () => {
|
||||
render(<Harness />)
|
||||
|
||||
const input = screen.getByLabelText('Search or add tags')
|
||||
await userEvent.type(input, 'high')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('18 recent clicks')).not.toBeNull()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /High Contrast/i }))
|
||||
|
||||
expect(screen.getByText('High Contrast')).not.toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Remove tag High Contrast' })).not.toBeNull()
|
||||
})
|
||||
})
|
||||
19
resources/js/lib/tagAnalytics.js
Normal file
19
resources/js/lib/tagAnalytics.js
Normal file
@@ -0,0 +1,19 @@
|
||||
export function sendTagInteractionEvent(payload) {
|
||||
const endpoint = '/api/analytics/tags'
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
const body = JSON.stringify(payload)
|
||||
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...(csrfToken ? { 'X-CSRF-TOKEN': csrfToken } : {}),
|
||||
},
|
||||
body,
|
||||
keepalive: true,
|
||||
credentials: 'same-origin',
|
||||
}).catch(() => {
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,7 @@ if (!window.Alpine) {
|
||||
|
||||
// Gallery navigation context: stores artwork list for prev/next on artwork page
|
||||
import './lib/nav-context.js';
|
||||
import { sendTagInteractionEvent } from './lib/tagAnalytics';
|
||||
|
||||
function mountStoryEditor() {
|
||||
var storyEditorRoot = document.getElementById('story-editor-react-root');
|
||||
@@ -61,6 +62,475 @@ function mountStoryEditor() {
|
||||
|
||||
mountStoryEditor();
|
||||
|
||||
function initTagsSearchAssist() {
|
||||
var roots = document.querySelectorAll('[data-tags-search-root]');
|
||||
if (!roots.length) return;
|
||||
|
||||
var recentSearchesKey = 'skinbase.tags.recent-searches';
|
||||
|
||||
function findClosest(el, selector) {
|
||||
while (el && el.nodeType === 1) {
|
||||
if (el.matches(selector)) return el;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
roots.forEach(function (root, rootIndex) {
|
||||
var form = findClosest(root, '[data-tags-search-form]');
|
||||
var input = root.querySelector('[data-tags-search-input]');
|
||||
var panel = root.querySelector('[data-tags-search-panel]');
|
||||
var title = root.querySelector('[data-tags-search-title]');
|
||||
var results = root.querySelector('[data-tags-search-results]');
|
||||
var endpoint = root.getAttribute('data-search-endpoint') || '/api/tags/search';
|
||||
var popularEndpoint = root.getAttribute('data-popular-endpoint') || '/api/tags/popular';
|
||||
var optionIdPrefix = 'tags-search-option-' + rootIndex + '-';
|
||||
var debounceTimer = null;
|
||||
var abortController = null;
|
||||
var latestQuery = '';
|
||||
var activeIndex = -1;
|
||||
|
||||
if (!input || !panel || !results) return;
|
||||
|
||||
function readRecentSearches() {
|
||||
try {
|
||||
var parsed = JSON.parse(window.localStorage.getItem(recentSearchesKey) || '[]');
|
||||
return Array.isArray(parsed) ? parsed.filter(Boolean).slice(0, 5) : [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeRecentSearch(query) {
|
||||
var normalized = (query || '').trim();
|
||||
if (!normalized) return;
|
||||
|
||||
var next = readRecentSearches().filter(function (item) {
|
||||
return item.toLowerCase() !== normalized.toLowerCase();
|
||||
});
|
||||
|
||||
next.unshift(normalized);
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(recentSearchesKey, JSON.stringify(next.slice(0, 5)));
|
||||
} catch (_error) {
|
||||
// Ignore storage failures silently.
|
||||
}
|
||||
}
|
||||
|
||||
function clearRecentSearches() {
|
||||
try {
|
||||
window.localStorage.removeItem(recentSearchesKey);
|
||||
} catch (_error) {
|
||||
// Ignore storage failures silently.
|
||||
}
|
||||
}
|
||||
|
||||
function removeRecentSearch(query) {
|
||||
var normalized = (query || '').trim().toLowerCase();
|
||||
if (!normalized) return;
|
||||
|
||||
var next = readRecentSearches().filter(function (item) {
|
||||
return item.toLowerCase() !== normalized;
|
||||
});
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(recentSearchesKey, JSON.stringify(next));
|
||||
} catch (_error) {
|
||||
// Ignore storage failures silently.
|
||||
}
|
||||
}
|
||||
|
||||
function getItems() {
|
||||
return Array.prototype.slice.call(results.querySelectorAll('[data-tags-search-item]'));
|
||||
}
|
||||
|
||||
function setActiveItem(nextIndex) {
|
||||
var items = getItems();
|
||||
activeIndex = nextIndex;
|
||||
|
||||
items.forEach(function (item, index) {
|
||||
var active = index === nextIndex;
|
||||
item.classList.toggle('bg-white/[0.06]', active);
|
||||
item.classList.toggle('text-white', active);
|
||||
item.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
if (!item.id) {
|
||||
item.id = optionIdPrefix + index;
|
||||
}
|
||||
if (active) {
|
||||
item.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
});
|
||||
|
||||
if (nextIndex >= 0 && items[nextIndex]) {
|
||||
input.setAttribute('aria-activedescendant', items[nextIndex].id);
|
||||
} else {
|
||||
input.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
}
|
||||
|
||||
function focusItem(nextIndex) {
|
||||
var items = getItems();
|
||||
if (!items.length) return;
|
||||
|
||||
var boundedIndex = Math.max(0, Math.min(nextIndex, items.length - 1));
|
||||
setActiveItem(boundedIndex);
|
||||
items[boundedIndex].focus();
|
||||
}
|
||||
|
||||
function setExpanded(expanded) {
|
||||
input.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
panel.classList.toggle('hidden', !expanded);
|
||||
if (!expanded) {
|
||||
activeIndex = -1;
|
||||
setActiveItem(-1);
|
||||
}
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
results.innerHTML = '';
|
||||
activeIndex = -1;
|
||||
}
|
||||
|
||||
function hidePanel() {
|
||||
setExpanded(false);
|
||||
}
|
||||
|
||||
function renderLoadingState(query) {
|
||||
title.textContent = query ? 'Searching tags' : 'Loading suggestions';
|
||||
results.setAttribute('aria-busy', 'true');
|
||||
results.innerHTML = '<div class="space-y-2 px-2 py-2">'
|
||||
+ '<div class="flex items-center justify-between gap-3 rounded-xl px-3 py-3">'
|
||||
+ '<div class="flex items-center gap-3"><span class="h-8 w-8 animate-pulse rounded-full bg-white/[0.08]"></span><span class="space-y-1"><span class="block h-3 w-24 animate-pulse rounded bg-white/[0.08]"></span><span class="block h-2.5 w-16 animate-pulse rounded bg-white/[0.05]"></span></span></div>'
|
||||
+ '<span class="h-3 w-10 animate-pulse rounded bg-white/[0.06]"></span>'
|
||||
+ '</div>'
|
||||
+ '<div class="flex items-center justify-between gap-3 rounded-xl px-3 py-3">'
|
||||
+ '<div class="flex items-center gap-3"><span class="h-8 w-8 animate-pulse rounded-full bg-white/[0.08]"></span><span class="space-y-1"><span class="block h-3 w-28 animate-pulse rounded bg-white/[0.08]"></span><span class="block h-2.5 w-20 animate-pulse rounded bg-white/[0.05]"></span></span></div>'
|
||||
+ '<span class="h-3 w-12 animate-pulse rounded bg-white/[0.06]"></span>'
|
||||
+ '</div>'
|
||||
+ '<div class="flex items-center justify-between gap-3 rounded-xl px-3 py-3">'
|
||||
+ '<div class="flex items-center gap-3"><span class="h-8 w-8 animate-pulse rounded-full bg-white/[0.08]"></span><span class="space-y-1"><span class="block h-3 w-20 animate-pulse rounded bg-white/[0.08]"></span><span class="block h-2.5 w-14 animate-pulse rounded bg-white/[0.05]"></span></span></div>'
|
||||
+ '<span class="h-3 w-8 animate-pulse rounded bg-white/[0.06]"></span>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
setExpanded(true);
|
||||
setActiveItem(-1);
|
||||
}
|
||||
|
||||
function fetchJson(url, signal) {
|
||||
return fetch(url, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
|
||||
signal: signal,
|
||||
}).then(function (response) {
|
||||
if (!response.ok) throw new Error('Failed to load tag suggestions');
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
function renderRecentSearches(items) {
|
||||
if (!items.length) return '';
|
||||
|
||||
return '<div class="px-2 pb-2">'
|
||||
+ '<div class="flex items-center justify-between gap-3 px-2 pb-2 pt-1">'
|
||||
+ '<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/30">Recent searches</div>'
|
||||
+ '<button type="button" data-tags-search-clear-recent class="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/48 transition hover:bg-white/[0.08] hover:text-white">Clear</button>'
|
||||
+ '</div>'
|
||||
+ '<div class="flex flex-wrap gap-2 px-2 pb-2">'
|
||||
+ items.map(function (item) {
|
||||
var encoded = encodeURIComponent(item);
|
||||
var escaped = escapeHtml(item);
|
||||
return '<span class="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.05] pr-1 text-xs font-medium text-white/68 transition hover:bg-white/[0.08] hover:text-white">'
|
||||
+ '<a href="/tags?q=' + encoded + '" data-tags-search-item data-tags-search-surface="recent_search" data-tags-search-query="' + escaped + '" role="option" aria-selected="false" class="rounded-full px-3 py-1.5">' + escaped + '</a>'
|
||||
+ '<button type="button" data-tags-search-remove-recent data-tags-search-query="' + escaped + '" class="inline-flex h-6 w-6 items-center justify-center rounded-full text-white/38 transition hover:bg-black/20 hover:text-white" aria-label="Remove recent search ' + escaped + '">'
|
||||
+ '×'
|
||||
+ '</button>'
|
||||
+ '</span>';
|
||||
}).join('')
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function renderRescueSuggestions(items, query) {
|
||||
if (!items.length) return '';
|
||||
|
||||
return '<div class="border-t border-white/8 px-2 pt-3">'
|
||||
+ '<div class="px-2 pb-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/30">Try these instead</div>'
|
||||
+ '<div class="grid gap-1.5 px-1 pb-2">'
|
||||
+ items.map(function (item, index) {
|
||||
var name = escapeHtml(item.name || '');
|
||||
var slug = escapeHtml(item.slug || '');
|
||||
return '<a href="/tag/' + slug + '" data-tags-search-item data-tags-search-surface="rescue_suggestion" data-tags-search-tag="' + slug + '" data-tags-search-query="' + escapeHtml(query || '') + '" data-tags-search-position="' + (index + 1) + '" role="option" aria-selected="false" class="flex items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-sm text-white/72 transition hover:bg-white/[0.06] hover:text-white">'
|
||||
+ '<span class="min-w-0"><span class="block truncate font-medium text-white">' + name + '</span><span class="block truncate text-xs text-white/34">#' + slug + '</span></span>'
|
||||
+ '<span class="shrink-0 text-xs text-white/30">Popular</span>'
|
||||
+ '</a>';
|
||||
}).join('')
|
||||
+ '</div>'
|
||||
+ '<div class="px-2 pb-2"><a href="/tags" class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-sky-200 transition hover:text-white">Browse full tags index</a></div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function renderNoMatchState(query, rescueItems) {
|
||||
title.textContent = 'No matching tags';
|
||||
results.setAttribute('aria-busy', 'false');
|
||||
results.innerHTML = '<div class="rounded-xl px-3 py-4 text-sm text-white/42">No direct matches for <span class="text-white">' + escapeHtml(query) + '</span>. Try a broader keyword or jump into one of these popular tags.</div>'
|
||||
+ renderRescueSuggestions(rescueItems || [], query);
|
||||
setExpanded(true);
|
||||
setActiveItem(-1);
|
||||
}
|
||||
|
||||
function renderItems(items, query) {
|
||||
clearResults();
|
||||
results.setAttribute('aria-busy', 'false');
|
||||
|
||||
var recentSearches = !query ? readRecentSearches() : [];
|
||||
|
||||
if (!items.length && recentSearches.length) {
|
||||
title.textContent = 'Recent searches';
|
||||
results.innerHTML = renderRecentSearches(recentSearches);
|
||||
setExpanded(true);
|
||||
setActiveItem(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
title.textContent = query ? 'Matching tags' : (recentSearches.length ? 'Recent and popular' : 'Popular tags');
|
||||
|
||||
if (recentSearches.length) {
|
||||
results.insertAdjacentHTML('beforeend', renderRecentSearches(recentSearches));
|
||||
}
|
||||
|
||||
items.forEach(function (item, index) {
|
||||
var link = document.createElement('a');
|
||||
var itemName = escapeHtml(item.name || '');
|
||||
var itemSlug = escapeHtml(item.slug || '');
|
||||
var recentClicks = Number(item.recent_clicks || 0);
|
||||
var metricLabel = recentClicks > 0
|
||||
? recentClicks.toLocaleString() + ' recent'
|
||||
: Number(item.usage_count || 0).toLocaleString() + ' uses';
|
||||
link.href = '/tag/' + item.slug;
|
||||
link.className = 'flex items-center justify-between gap-3 rounded-xl px-3 py-3 text-sm text-white/72 transition hover:bg-white/[0.06] hover:text-white';
|
||||
link.setAttribute('data-tags-search-item', '');
|
||||
link.setAttribute('data-tags-search-surface', 'search_suggestion');
|
||||
link.setAttribute('data-tags-search-tag', item.slug || '');
|
||||
link.setAttribute('data-tags-search-query', item.name || item.slug || '');
|
||||
link.setAttribute('data-tags-search-input', query || '');
|
||||
link.setAttribute('data-tags-search-position', String(index + 1));
|
||||
link.setAttribute('aria-selected', 'false');
|
||||
link.setAttribute('role', 'option');
|
||||
link.tabIndex = -1;
|
||||
link.innerHTML =
|
||||
'<span class="flex min-w-0 items-center gap-3">'
|
||||
+ '<span class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-white/[0.06] text-xs text-sky-200">#</span>'
|
||||
+ '<span class="min-w-0"><span class="block truncate font-medium text-white">' + itemName + '</span><span class="block truncate text-xs text-white/36">#' + itemSlug + '</span></span>'
|
||||
+ '</span>'
|
||||
+ '<span class="shrink-0 text-xs text-white/34">' + metricLabel + '</span>';
|
||||
results.appendChild(link);
|
||||
});
|
||||
|
||||
setExpanded(true);
|
||||
setActiveItem(-1);
|
||||
}
|
||||
|
||||
function fetchSuggestions(query) {
|
||||
if (abortController) abortController.abort();
|
||||
abortController = new AbortController();
|
||||
latestQuery = query;
|
||||
renderLoadingState(query);
|
||||
|
||||
var params = new URLSearchParams();
|
||||
if (query) params.set('q', query);
|
||||
|
||||
fetchJson(endpoint + (params.toString() ? ('?' + params.toString()) : ''), abortController.signal)
|
||||
.then(function (payload) {
|
||||
if (input.value.trim() !== latestQuery) return;
|
||||
var items = Array.isArray(payload.data) ? payload.data.slice(0, 8) : [];
|
||||
if (items.length || query === '') {
|
||||
renderItems(items, query);
|
||||
return;
|
||||
}
|
||||
|
||||
var popularParams = new URLSearchParams();
|
||||
popularParams.set('limit', '4');
|
||||
|
||||
return fetchJson(popularEndpoint + '?' + popularParams.toString(), abortController.signal)
|
||||
.then(function (popularPayload) {
|
||||
if (input.value.trim() !== latestQuery) return;
|
||||
renderNoMatchState(query, Array.isArray(popularPayload.data) ? popularPayload.data : []);
|
||||
});
|
||||
})
|
||||
.catch(function (error) {
|
||||
if (error && error.name === 'AbortError') return;
|
||||
hidePanel();
|
||||
});
|
||||
}
|
||||
|
||||
input.addEventListener('focus', function () {
|
||||
fetchSuggestions(input.value.trim());
|
||||
});
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
var query = input.value.trim();
|
||||
window.clearTimeout(debounceTimer);
|
||||
debounceTimer = window.setTimeout(function () {
|
||||
fetchSuggestions(query);
|
||||
}, 180);
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', function (event) {
|
||||
var items = getItems();
|
||||
|
||||
if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && items.length) {
|
||||
event.preventDefault();
|
||||
if (event.key === 'ArrowDown') {
|
||||
setActiveItem(activeIndex < 0 ? 0 : Math.min(activeIndex + 1, items.length - 1));
|
||||
} else {
|
||||
setActiveItem(activeIndex < 0 ? items.length - 1 : Math.max(activeIndex - 1, 0));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && activeIndex >= 0 && items[activeIndex]) {
|
||||
event.preventDefault();
|
||||
writeRecentSearch(items[activeIndex].getAttribute('data-tags-search-query') || input.value);
|
||||
window.location.href = items[activeIndex].href;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
hidePanel();
|
||||
}
|
||||
});
|
||||
|
||||
results.addEventListener('keydown', function (event) {
|
||||
var items = getItems();
|
||||
var currentIndex = items.indexOf(document.activeElement);
|
||||
|
||||
if (event.key === 'ArrowDown' && items.length) {
|
||||
event.preventDefault();
|
||||
focusItem(currentIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' && items.length) {
|
||||
event.preventDefault();
|
||||
if (currentIndex <= 0) {
|
||||
setActiveItem(-1);
|
||||
input.focus();
|
||||
} else {
|
||||
focusItem(currentIndex - 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
hidePanel();
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
|
||||
results.addEventListener('mouseover', function (event) {
|
||||
var item = findClosest(event.target, '[data-tags-search-item]');
|
||||
if (!item) return;
|
||||
var items = getItems();
|
||||
setActiveItem(items.indexOf(item));
|
||||
});
|
||||
|
||||
results.addEventListener('click', function (event) {
|
||||
var clearButton = findClosest(event.target, '[data-tags-search-clear-recent]');
|
||||
if (clearButton) {
|
||||
event.preventDefault();
|
||||
clearRecentSearches();
|
||||
fetchSuggestions(input.value.trim());
|
||||
return;
|
||||
}
|
||||
|
||||
var removeButton = findClosest(event.target, '[data-tags-search-remove-recent]');
|
||||
if (removeButton) {
|
||||
event.preventDefault();
|
||||
removeRecentSearch(removeButton.getAttribute('data-tags-search-query') || '');
|
||||
fetchSuggestions(input.value.trim());
|
||||
return;
|
||||
}
|
||||
|
||||
var item = findClosest(event.target, '[data-tags-search-item]');
|
||||
if (!item) return;
|
||||
var query = item.getAttribute('data-tags-search-query') || item.textContent || '';
|
||||
writeRecentSearch(query);
|
||||
|
||||
var surface = item.getAttribute('data-tags-search-surface') || '';
|
||||
var tagSlug = item.getAttribute('data-tags-search-tag') || '';
|
||||
|
||||
if (surface && (tagSlug || surface === 'recent_search')) {
|
||||
sendTagInteractionEvent({
|
||||
event_type: 'click',
|
||||
surface: surface,
|
||||
tag_slug: tagSlug || null,
|
||||
query: item.getAttribute('data-tags-search-input') || item.getAttribute('data-tags-search-query') || input.value.trim() || null,
|
||||
position: Number(item.getAttribute('data-tags-search-position') || 0) || null,
|
||||
occurred_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('submit', function () {
|
||||
writeRecentSearch(input.value);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
if (!root.contains(event.target)) {
|
||||
hidePanel();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initTagsSearchAssist();
|
||||
|
||||
function initTagAnalyticsLinks() {
|
||||
document.addEventListener('click', function (event) {
|
||||
var el = event.target;
|
||||
|
||||
while (el && el.nodeType === 1) {
|
||||
if (el.matches('[data-tag-analytics-link]')) {
|
||||
var tagSlug = el.getAttribute('data-tag-analytics-tag') || '';
|
||||
var surface = el.getAttribute('data-tag-analytics-surface') || '';
|
||||
|
||||
if (tagSlug && surface) {
|
||||
sendTagInteractionEvent({
|
||||
event_type: 'click',
|
||||
surface: surface,
|
||||
tag_slug: tagSlug,
|
||||
source_tag_slug: el.getAttribute('data-tag-analytics-source-tag') || null,
|
||||
position: Number(el.getAttribute('data-tag-analytics-position') || 0) || null,
|
||||
occurred_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
el = el.parentElement;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initTagAnalyticsLinks();
|
||||
|
||||
(function () {
|
||||
function initBlurPreviewImages() {
|
||||
var selector = 'img[data-blur-preview]';
|
||||
|
||||
@@ -2,9 +2,23 @@
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8 py-10">
|
||||
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Reports Queue</h1>
|
||||
<p class="mt-2 text-sm text-gray-500">Use the API endpoint <code>/api/reports</code> to submit reports and review records in the <code>reports</code> table.</p>
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Reports Hub</h1>
|
||||
<p class="mt-2 text-sm text-gray-500">Internal reporting entry points for moderation and discovery analytics.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<a href="{{ route('admin.reports.tags') }}" class="rounded-xl border border-sky-200 bg-sky-50 p-6 transition hover:border-sky-300 hover:bg-sky-100/80 dark:border-sky-900/60 dark:bg-sky-950/30 dark:hover:border-sky-700 dark:hover:bg-sky-950/50">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-sky-100">Tag Interaction Report</h2>
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-sky-200/70">Inspect top surfaces, tags, search terms, and related-tag transitions from the new tag analytics pipeline.</p>
|
||||
</a>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Moderation Queue</h2>
|
||||
<p class="mt-2 text-sm text-gray-500">Use the API endpoint <code>/api/reports</code> to submit reports and review records in the <code>reports</code> table.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
216
resources/views/admin/reports/tags.blade.php
Normal file
216
resources/views/admin/reports/tags.blade.php
Normal file
@@ -0,0 +1,216 @@
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@section('page-content')
|
||||
<div class="mx-auto max-w-7xl space-y-8">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Tag Interaction Report</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-neutral-400">
|
||||
Internal dashboard for tag discovery clicks. Use it to inspect surface performance, top tags, query demand, and tag-to-tag transitions for recommendation tuning.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 text-xs">
|
||||
<a href="{{ route('api.admin.reports.tags', request()->query()) }}" class="inline-flex items-center rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2 font-medium text-neutral-200 transition hover:border-sky-500 hover:text-white">JSON report</a>
|
||||
<a href="{{ route('admin.reports.queue') }}" class="inline-flex items-center rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2 font-medium text-neutral-200 transition hover:border-sky-500 hover:text-white">Reports hub</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<form method="GET" action="{{ route('admin.reports.tags') }}" class="grid gap-4 md:grid-cols-4">
|
||||
<label class="space-y-2 text-sm text-neutral-300">
|
||||
<span class="block text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">From</span>
|
||||
<input type="date" name="from" value="{{ $filters['from'] }}" class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-white focus:border-sky-500 focus:outline-none">
|
||||
</label>
|
||||
<label class="space-y-2 text-sm text-neutral-300">
|
||||
<span class="block text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">To</span>
|
||||
<input type="date" name="to" value="{{ $filters['to'] }}" class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-white focus:border-sky-500 focus:outline-none">
|
||||
</label>
|
||||
<label class="space-y-2 text-sm text-neutral-300">
|
||||
<span class="block text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Row limit</span>
|
||||
<input type="number" min="1" max="100" name="limit" value="{{ $filters['limit'] }}" class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-white focus:border-sky-500 focus:outline-none">
|
||||
</label>
|
||||
<div class="flex items-end gap-3">
|
||||
<button type="submit" class="inline-flex items-center rounded-lg bg-sky-500 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-400">Refresh</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-4 text-xs text-neutral-500">
|
||||
<span>Latest aggregated date: <span class="font-medium text-neutral-300">{{ $latestAggregatedDate ?? 'not aggregated yet' }}</span></span>
|
||||
<span>Latest raw event: <span class="font-medium text-neutral-300">{{ $overview['latest_event_at'] ?? 'n/a' }}</span></span>
|
||||
</div>
|
||||
|
||||
@if(app()->environment('local'))
|
||||
<div class="mt-4 rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-100">
|
||||
<p class="font-semibold">Local demo data</p>
|
||||
<p class="mt-1 text-amber-100/80">
|
||||
This report can be filled locally with seeded click data. Run
|
||||
<code class="rounded bg-black/30 px-2 py-1 text-xs text-amber-50">php artisan analytics:seed-tag-interaction-demo --days=14 --per-day=80 --refresh</code>
|
||||
and refresh this page to inspect realistic search, recommendation, and transition metrics.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Total clicks</p>
|
||||
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['total_clicks']) }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Unique users</p>
|
||||
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['unique_users']) }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Unique sessions</p>
|
||||
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['unique_sessions']) }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Distinct tags</p>
|
||||
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['distinct_tags']) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Daily Click Trend</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500">Daily rollups for tuning trending and recommendation decisions.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@forelse($dailyClicks as $row)
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-950/70 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-neutral-500">{{ $row['date'] }}</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($row['clicks']) }}</p>
|
||||
<p class="mt-1 text-xs text-neutral-500">clicks</p>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-neutral-500">No aggregated rows available for the selected range yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<h2 class="text-lg font-semibold text-white">Top Surfaces</h2>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
|
||||
<th class="pb-3 pr-4">Surface</th>
|
||||
<th class="pb-3 pr-4">Clicks</th>
|
||||
<th class="pb-3 pr-4">Users</th>
|
||||
<th class="pb-3 pr-4">Sessions</th>
|
||||
<th class="pb-3">Avg pos.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($bySurface as $row)
|
||||
<tr class="border-b border-neutral-800/70 text-neutral-200">
|
||||
<td class="py-3 pr-4 font-medium text-white">{{ $row['surface'] }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['unique_users']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['unique_sessions']) }}</td>
|
||||
<td class="py-3">{{ $row['avg_position'] > 0 ? number_format($row['avg_position'], 2) : 'n/a' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="py-4 text-neutral-500">No surface data in this range.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<h2 class="text-lg font-semibold text-white">Top Query Terms</h2>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
|
||||
<th class="pb-3 pr-4">Query</th>
|
||||
<th class="pb-3 pr-4">Clicks</th>
|
||||
<th class="pb-3 pr-4">Sessions</th>
|
||||
<th class="pb-3">Resolved tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($topQueries as $row)
|
||||
<tr class="border-b border-neutral-800/70 text-neutral-200">
|
||||
<td class="py-3 pr-4 font-medium text-white">{{ $row['query'] }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['unique_sessions']) }}</td>
|
||||
<td class="py-3">{{ number_format($row['resolved_tags']) }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4" class="py-4 text-neutral-500">No query data in this range.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<h2 class="text-lg font-semibold text-white">Top Tags</h2>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
|
||||
<th class="pb-3 pr-4">Tag</th>
|
||||
<th class="pb-3 pr-4">Clicks</th>
|
||||
<th class="pb-3 pr-4">Recommendation</th>
|
||||
<th class="pb-3 pr-4">Search</th>
|
||||
<th class="pb-3">Sessions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($topTags as $row)
|
||||
<tr class="border-b border-neutral-800/70 text-neutral-200">
|
||||
<td class="py-3 pr-4 font-medium text-white">#{{ $row['tag_slug'] }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['recommendation_clicks']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['search_clicks']) }}</td>
|
||||
<td class="py-3">{{ number_format($row['unique_sessions']) }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="py-4 text-neutral-500">No tag click data in this range.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<h2 class="text-lg font-semibold text-white">Top Tag Transitions</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500">Most-clicked source tag to target tag paths from related-tag surfaces.</p>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
|
||||
<th class="pb-3 pr-4">Source</th>
|
||||
<th class="pb-3 pr-4">Target</th>
|
||||
<th class="pb-3 pr-4">Clicks</th>
|
||||
<th class="pb-3 pr-4">Sessions</th>
|
||||
<th class="pb-3">Avg pos.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($topTransitions as $row)
|
||||
<tr class="border-b border-neutral-800/70 text-neutral-200">
|
||||
<td class="py-3 pr-4 font-medium text-white">#{{ $row['source_tag_slug'] }}</td>
|
||||
<td class="py-3 pr-4 font-medium text-white">#{{ $row['tag_slug'] }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['unique_sessions']) }}</td>
|
||||
<td class="py-3">{{ $row['avg_position'] > 0 ? number_format($row['avg_position'], 2) : 'n/a' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="py-4 text-neutral-500">No transition data in this range.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
71
resources/views/components/nova-page-header.blade.php
Normal file
71
resources/views/components/nova-page-header.blade.php
Normal file
@@ -0,0 +1,71 @@
|
||||
@props([
|
||||
'section' => null,
|
||||
'title' => null,
|
||||
'icon' => null,
|
||||
'breadcrumbs' => collect(),
|
||||
'description' => null,
|
||||
'showSection' => true,
|
||||
'showIcon' => true,
|
||||
'showTitle' => true,
|
||||
'showBreadcrumbs' => true,
|
||||
'showDescription' => true,
|
||||
'headerClass' => '',
|
||||
'innerClass' => '',
|
||||
'contentClass' => '',
|
||||
'actionsClass' => '',
|
||||
'sectionClass' => '',
|
||||
'titleClass' => '',
|
||||
'iconClass' => '',
|
||||
'descriptionClass' => '',
|
||||
])
|
||||
|
||||
@php
|
||||
$headerBreadcrumbs = $breadcrumbs instanceof \Illuminate\Support\Collection
|
||||
? $breadcrumbs
|
||||
: collect($breadcrumbs ?? []);
|
||||
|
||||
$hasActions = isset($actions) && trim((string) $actions) !== '';
|
||||
$resolvedHeaderClass = trim('px-6 pt-10 pb-7 md:px-10 border-b border-white/[0.06] bg-gradient-to-b from-sky-500/[0.04] to-transparent ' . $headerClass);
|
||||
$resolvedInnerClass = trim('flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between ' . $innerClass);
|
||||
$resolvedContentClass = trim('max-w-3xl min-w-0 ' . $contentClass);
|
||||
$resolvedActionsClass = trim('flex flex-col items-start gap-3 lg:items-end ' . $actionsClass);
|
||||
$resolvedSectionClass = trim('text-xs font-semibold uppercase tracking-widest text-white/30 mb-1 ' . $sectionClass);
|
||||
$resolvedTitleClass = trim('text-3xl font-bold text-white leading-tight flex items-center gap-3 ' . $titleClass);
|
||||
$resolvedIconClass = trim('text-sky-400 text-2xl ' . $iconClass);
|
||||
$resolvedDescriptionClass = trim('mt-1 text-sm text-white/50 ' . $descriptionClass);
|
||||
@endphp
|
||||
|
||||
<header {{ $attributes->class([$resolvedHeaderClass]) }}>
|
||||
<div class="{{ $resolvedInnerClass }}">
|
||||
<div class="{{ $resolvedContentClass }}">
|
||||
@if($showSection && filled($section))
|
||||
<p class="{{ $resolvedSectionClass }}">{{ $section }}</p>
|
||||
@endif
|
||||
|
||||
@if($showTitle && filled($title))
|
||||
<h1 class="{{ $resolvedTitleClass }}">
|
||||
@if($showIcon && filled($icon))
|
||||
<i class="fa-solid {{ $icon }} {{ $resolvedIconClass }}"></i>
|
||||
@endif
|
||||
<span class="min-w-0">{{ $title }}</span>
|
||||
</h1>
|
||||
@endif
|
||||
|
||||
@if($showBreadcrumbs && $headerBreadcrumbs->isNotEmpty())
|
||||
<div class="mt-3">
|
||||
@include('components.breadcrumbs', ['breadcrumbs' => $headerBreadcrumbs])
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($showDescription && filled($description))
|
||||
<p class="{{ $resolvedDescriptionClass }}">{!! $description !!}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($hasActions)
|
||||
<div class="{{ $resolvedActionsClass }}">
|
||||
{{ $actions }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</header>
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
@php
|
||||
$active = $section ?? 'artworks';
|
||||
$includeTags = (bool) ($includeTags ?? false);
|
||||
|
||||
$sections = collect([
|
||||
'artworks' => ['label' => 'All Artworks', 'icon' => 'fa-border-all', 'href' => '/browse'],
|
||||
@@ -14,6 +15,10 @@
|
||||
'skins' => ['label' => 'Skins', 'icon' => 'fa-layer-group', 'href' => '/skins'],
|
||||
'other' => ['label' => 'Other', 'icon' => 'fa-folder-open', 'href' => '/other'],
|
||||
]);
|
||||
|
||||
if ($includeTags) {
|
||||
$sections->put('tags', ['label' => 'Tags', 'icon' => 'fa-tags', 'href' => '/tags']);
|
||||
}
|
||||
@endphp
|
||||
|
||||
<nav class="flex flex-wrap items-center gap-2 text-sm" aria-label="Browse sections">
|
||||
|
||||
@@ -69,6 +69,8 @@
|
||||
$rankApiEndpoint = '/api/rank/global';
|
||||
}
|
||||
}
|
||||
|
||||
$tagContext = ($gallery_type ?? null) === 'tag' ? ($tag_context ?? null) : null;
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
@@ -76,13 +78,15 @@
|
||||
@php Banner::ShowResponsiveAd(); @endphp
|
||||
|
||||
@php
|
||||
$browseSection = isset($contentType) && $contentType ? strtolower((string) $contentType->slug) : 'artworks';
|
||||
$browseSection = $gallery_nav_section
|
||||
?? (isset($contentType) && $contentType ? strtolower((string) $contentType->slug) : (($gallery_type ?? null) === 'tag' ? 'tags' : 'artworks'));
|
||||
$browseIconMap = [
|
||||
'artworks' => 'fa-border-all',
|
||||
'photography' => 'fa-camera',
|
||||
'wallpapers' => 'fa-desktop',
|
||||
'skins' => 'fa-layer-group',
|
||||
'other' => 'fa-folder-open',
|
||||
'tags' => 'fa-tags',
|
||||
];
|
||||
$browseIcon = $browseIconMap[$browseSection] ?? 'fa-border-all';
|
||||
@endphp
|
||||
@@ -94,39 +98,141 @@
|
||||
<main class="w-full">
|
||||
|
||||
{{-- ── Hero header (discover-style) ── --}}
|
||||
<header class="px-6 pt-10 pb-7 md:px-10 border-b border-white/[0.06] bg-gradient-to-b from-sky-500/[0.04] to-transparent">
|
||||
@php
|
||||
$headerBreadcrumbs = collect();
|
||||
|
||||
if (($gallery_type ?? null) === 'browse') {
|
||||
$headerBreadcrumbs = collect([
|
||||
(object) ['name' => 'Browse', 'url' => '/browse'],
|
||||
]);
|
||||
} elseif (isset($contentType) && $contentType) {
|
||||
$headerBreadcrumbs = collect([
|
||||
(object) ['name' => 'Browse', 'url' => '/browse'],
|
||||
(object) ['name' => $contentType->name, 'url' => '/' . strtolower($contentType->slug)],
|
||||
]);
|
||||
|
||||
if (($gallery_type ?? null) === 'category' && isset($breadcrumbs) && $breadcrumbs->isNotEmpty()) {
|
||||
$headerBreadcrumbs = $breadcrumbs;
|
||||
}
|
||||
} elseif (isset($breadcrumbs) && $breadcrumbs->isNotEmpty()) {
|
||||
$headerBreadcrumbs = $breadcrumbs;
|
||||
}
|
||||
@endphp
|
||||
|
||||
<x-nova-page-header
|
||||
section="{{ ($gallery_type ?? null) === 'tag' ? 'Tags' : 'Browse' }}"
|
||||
:title="$hero_title ?? 'Browse Artworks'"
|
||||
:icon="$browseIcon"
|
||||
:breadcrumbs="$headerBreadcrumbs"
|
||||
:description="$hero_description ?? null"
|
||||
actionsClass="lg:pt-8"
|
||||
>
|
||||
<x-slot name="actions">
|
||||
@include('gallery._browse_nav', ['section' => $browseSection, 'includeTags' => ($gallery_type ?? null) === 'tag'])
|
||||
</x-slot>
|
||||
</x-nova-page-header>
|
||||
|
||||
@if($tagContext)
|
||||
<section class="px-6 pt-8 md:px-10">
|
||||
@php
|
||||
$headerBreadcrumbs = collect(array_filter([
|
||||
isset($contentType) && $contentType ? (object) ['name' => 'Explore', 'url' => '/explore'] : null,
|
||||
isset($contentType) && $contentType ? (object) ['name' => $contentType->name, 'url' => '/explore/' . strtolower($contentType->slug)] : (object) ['name' => 'Explore', 'url' => '/explore'],
|
||||
...(($gallery_type ?? null) === 'category' && isset($breadcrumbs) ? $breadcrumbs->all() : []),
|
||||
]));
|
||||
$topCompanionTag = collect($tagContext['related_tags'] ?? [])->first();
|
||||
@endphp
|
||||
<div class="overflow-hidden rounded-[1.75rem] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_34%),linear-gradient(135deg,rgba(11,17,27,0.96),rgba(10,16,24,0.88))] shadow-[0_20px_70px_rgba(3,7,18,0.24)]">
|
||||
<div class="grid gap-6 p-6 md:p-7 xl:grid-cols-[minmax(0,1.35fr)_minmax(300px,0.85fr)] xl:items-start">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm text-white/56">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200">
|
||||
<span class="h-2 w-2 rounded-full bg-sky-300"></span>
|
||||
Tag feed
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-medium text-white/60">
|
||||
Sorted by {{ $tagContext['current_sort_label'] ?? 'Most viewed' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="max-w-3xl">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Browse</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight flex items-center gap-3">
|
||||
<i class="fa-solid {{ $browseIcon }} text-sky-400 text-2xl"></i>
|
||||
{{ $hero_title ?? 'Browse Artworks' }}
|
||||
</h1>
|
||||
@if(!empty($hero_description))
|
||||
<p class="mt-1 text-sm text-white/50">{!! $hero_description !!}</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-2xl font-semibold tracking-tight text-white md:text-3xl">
|
||||
#{{ $tagContext['slug'] ?? $tagContext['name'] }}
|
||||
</h2>
|
||||
<p class="max-w-3xl text-sm leading-6 text-white/62 md:text-base">
|
||||
A focused feed for artwork tied to this theme. Use the ranking tabs to switch between momentum, recency, and quality without leaving the tag context.
|
||||
</p>
|
||||
@if($topCompanionTag && isset($topCompanionTag->shared_artworks_count))
|
||||
<a href="{{ route('tags.show', $topCompanionTag->slug) }}" data-tag-analytics-link data-tag-analytics-surface="top_companion" data-tag-analytics-tag="{{ $topCompanionTag->slug }}" data-tag-analytics-source-tag="{{ $tagContext['slug'] ?? '' }}" data-tag-analytics-position="1" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3.5 py-2 text-sm text-white/68 transition hover:border-sky-400/28 hover:bg-sky-400/[0.08] hover:text-white">
|
||||
<span class="text-sky-200">Top companion</span>
|
||||
<span class="font-medium text-white">#{{ $topCompanionTag->name }}</span>
|
||||
<span class="text-white/34">{{ number_format($topCompanionTag->shared_artworks_count) }} shared artworks</span>
|
||||
@if(isset($topCompanionTag->transition_clicks) && (int) $topCompanionTag->transition_clicks > 0)
|
||||
<span class="text-emerald-200">{{ number_format((int) $topCompanionTag->transition_clicks) }} recent clicks</span>
|
||||
@endif
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-3 lg:items-end">
|
||||
<div class="hidden lg:flex lg:justify-end">
|
||||
@include('components.breadcrumbs', ['breadcrumbs' => $headerBreadcrumbs])
|
||||
@if(collect($tagContext['related_tags'] ?? [])->isNotEmpty())
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/38">Related tags</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2.5">
|
||||
@foreach($tagContext['related_tags'] as $relatedIndex => $relatedTag)
|
||||
<a href="{{ route('tags.show', $relatedTag->slug) }}" data-tag-analytics-link data-tag-analytics-surface="related_chip" data-tag-analytics-tag="{{ $relatedTag->slug }}" data-tag-analytics-source-tag="{{ $tagContext['slug'] ?? '' }}" data-tag-analytics-position="{{ $relatedIndex + 1 }}" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-3.5 py-2 text-sm text-white/72 transition hover:border-sky-400/28 hover:bg-sky-400/[0.08] hover:text-white">
|
||||
<span>#{{ $relatedTag->name }}</span>
|
||||
<span class="text-xs text-white/36">
|
||||
{{ number_format($relatedTag->shared_artworks_count ?? $relatedTag->usage_count) }}
|
||||
{{ isset($relatedTag->shared_artworks_count) ? 'shared' : 'uses' }}
|
||||
</span>
|
||||
@if(isset($relatedTag->transition_clicks) && (int) $relatedTag->transition_clicks > 0)
|
||||
<span class="text-xs text-emerald-200">{{ number_format((int) $relatedTag->transition_clicks) }} recent</span>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-3">
|
||||
@foreach(collect($tagContext['related_tags'])->take(3)->values() as $clusterIndex => $clusterTag)
|
||||
<a href="{{ route('tags.show', $clusterTag->slug) }}" data-tag-analytics-link data-tag-analytics-surface="related_cluster" data-tag-analytics-tag="{{ $clusterTag->slug }}" data-tag-analytics-source-tag="{{ $tagContext['slug'] ?? '' }}" data-tag-analytics-position="{{ $clusterIndex + 1 }}" class="rounded-2xl border border-white/10 bg-white/[0.04] p-4 transition hover:border-sky-400/28 hover:bg-sky-400/[0.08]">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/34">Related cluster</p>
|
||||
<h3 class="mt-2 text-base font-semibold text-white">#{{ $clusterTag->name }}</h3>
|
||||
<p class="mt-2 text-sm text-white/52">
|
||||
{{ number_format($clusterTag->shared_artworks_count ?? 0) }} shared artworks with this tag feed.
|
||||
</p>
|
||||
@if(isset($clusterTag->transition_clicks) && (int) $clusterTag->transition_clicks > 0)
|
||||
<p class="mt-2 text-xs font-medium uppercase tracking-[0.18em] text-emerald-200">{{ number_format((int) $clusterTag->transition_clicks) }} recent clicks</p>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@include('gallery._browse_nav', ['section' => $browseSection])
|
||||
|
||||
<aside class="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/40">Artworks</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format((int) ($tagContext['artworks_total'] ?? 0)) }}</p>
|
||||
<p class="mt-2 text-sm text-white/45">Public approved artworks currently in this feed.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/40">Total uses</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format((int) ($tagContext['usage_count'] ?? 0)) }}</p>
|
||||
<p class="mt-2 text-sm text-white/45">How often this tag is attached across the catalog.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/40">Feed tools</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<a href="{{ route('tags.index') }}" class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm font-medium text-white/72 transition hover:bg-white/[0.08] hover:text-white">
|
||||
All tags
|
||||
</a>
|
||||
<a href="{{ $tagContext['rss_url'] ?? '#' }}" class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm font-medium text-white/72 transition hover:bg-white/[0.08] hover:text-white">
|
||||
RSS
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-white/45">Jump back to discovery or subscribe to this tag feed.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 lg:hidden">
|
||||
@include('components.breadcrumbs', ['breadcrumbs' => $headerBreadcrumbs])
|
||||
</div>
|
||||
</header>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- ═══════════════════════════════════════════════════════════════ --}}
|
||||
{{-- RANKING TABS --}}
|
||||
@@ -188,9 +294,11 @@
|
||||
@php
|
||||
$filterItems = $subcategories ?? collect();
|
||||
$activeFilterId = isset($category) ? ($category->id ?? null) : null;
|
||||
$categoryAllHref = isset($contentType) && $contentType
|
||||
? url('/' . $contentType->slug)
|
||||
: url('/browse');
|
||||
$categoryAllHref = isset($subcategory_parent) && $subcategory_parent && ($subcategory_parent->url ?? null)
|
||||
? url($subcategory_parent->url)
|
||||
: (isset($contentType) && $contentType
|
||||
? url('/' . $contentType->slug)
|
||||
: url('/browse'));
|
||||
$activeSortSlug = $activeTab !== 'trending' ? $activeTab : null;
|
||||
@endphp
|
||||
|
||||
@@ -382,7 +490,6 @@
|
||||
@push('scripts')
|
||||
@vite('resources/js/entry-masonry-gallery.jsx')
|
||||
@vite('resources/js/entry-pill-carousel.jsx')
|
||||
<script src="/js/legacy-gallery-init.js" defer></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -1,34 +1,308 @@
|
||||
@extends('layouts.nova.content-layout')
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
$hero_title = 'Tags';
|
||||
$hero_description = 'Browse all artwork tags on Skinbase.';
|
||||
$breadcrumbs = $breadcrumbs ?? collect([
|
||||
(object) ['name' => 'Explore', 'url' => '/explore'],
|
||||
(object) ['name' => 'Browse', 'url' => '/browse'],
|
||||
(object) ['name' => 'Tags', 'url' => '/tags'],
|
||||
]);
|
||||
@endphp
|
||||
|
||||
@section('page-content')
|
||||
@if($tags->isNotEmpty())
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach($tags as $tag)
|
||||
<a href="{{ route('tags.show', $tag->slug) }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/[0.05] border border-white/[0.07]
|
||||
text-sm text-white/70 hover:bg-white/[0.1] hover:text-white transition-colors">
|
||||
<i class="fa-solid fa-hashtag text-xs text-sky-400/70"></i>
|
||||
{{ $tag->name }}
|
||||
<span class="text-xs text-white/30 ml-1">{{ number_format($tag->artworks_count) }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@push('head')
|
||||
@isset($page_canonical)
|
||||
<link rel="canonical" href="{{ $page_canonical }}" />
|
||||
@endisset
|
||||
<meta name="robots" content="{{ $page_robots ?? 'index,follow' }}">
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="{{ $page_canonical ?? url()->current() }}" />
|
||||
<meta property="og:title" content="{{ $page_title ?? 'Skinbase' }}" />
|
||||
<meta property="og:description" content="{{ $page_meta_description ?? $hero_description }}" />
|
||||
<meta property="og:site_name" content="Skinbase" />
|
||||
@endpush
|
||||
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ $tags->withQueryString()->links() }}
|
||||
@section('content')
|
||||
@php
|
||||
$query = trim((string) ($query ?? ''));
|
||||
$featuredTags = $featuredTags ?? collect();
|
||||
$risingTags = $risingTags ?? collect();
|
||||
$tagStats = $tagStats ?? ['active' => 0, 'usage' => 0, 'matching' => $tags->total(), 'recent_clicks' => 0];
|
||||
$topFeaturedTag = $featuredTags->first();
|
||||
@endphp
|
||||
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="pt-0">
|
||||
<div class="mx-auto w-full">
|
||||
<div class="relative min-h-[calc(100vh-64px)]">
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 overflow-hidden">
|
||||
<div class="absolute left-[-6rem] top-10 h-56 w-56 rounded-full bg-sky-500/10 blur-3xl"></div>
|
||||
<div class="absolute right-[-4rem] top-24 h-64 w-64 rounded-full bg-cyan-400/10 blur-3xl"></div>
|
||||
<div class="absolute left-1/3 top-56 h-48 w-48 rounded-full bg-emerald-400/10 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<main class="w-full">
|
||||
<x-nova-page-header
|
||||
section="Browse"
|
||||
title="Tags"
|
||||
icon="fa-tags"
|
||||
:breadcrumbs="$breadcrumbs"
|
||||
:description="$hero_description"
|
||||
actionsClass="lg:pt-8"
|
||||
>
|
||||
<x-slot name="actions">
|
||||
@include('gallery._browse_nav', ['section' => 'tags', 'includeTags' => true])
|
||||
</x-slot>
|
||||
</x-nova-page-header>
|
||||
|
||||
<section class="px-6 pb-16 pt-8 md:px-10">
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(320px,0.9fr)]">
|
||||
<div class="overflow-hidden rounded-[1.75rem] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_38%),linear-gradient(135deg,rgba(13,19,30,0.98),rgba(8,14,24,0.92))] shadow-[0_24px_80px_rgba(3,7,18,0.32)]">
|
||||
<div class="grid gap-8 p-6 md:p-8 xl:grid-cols-[minmax(0,1.2fr)_minmax(260px,0.8fr)] xl:items-end">
|
||||
<div class="space-y-6">
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200">
|
||||
<span class="h-2 w-2 rounded-full bg-sky-300"></span>
|
||||
Explore by vibe, medium, and theme
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h2 class="max-w-2xl text-3xl font-semibold tracking-tight text-white md:text-4xl xl:text-[2.8rem] xl:leading-[1.05]">
|
||||
Find collections faster with a cleaner tag browsing experience.
|
||||
</h2>
|
||||
<p class="max-w-2xl text-sm leading-6 text-white/64 md:text-base">
|
||||
Jump into the most used themes on Skinbase, search by keyword, and move from discovery to relevant artwork feeds without scanning an endless wall of chips.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="GET" action="{{ route('tags.index') }}" class="space-y-3" data-tags-search-form>
|
||||
<label for="tags-search" class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">Search tags</label>
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<div class="relative flex-1" data-tags-search-root data-search-endpoint="/api/tags/search" data-popular-endpoint="/api/tags/popular">
|
||||
<i class="fa-solid fa-magnifying-glass pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-sm text-white/35"></i>
|
||||
<input
|
||||
id="tags-search"
|
||||
type="search"
|
||||
name="q"
|
||||
value="{{ $query }}"
|
||||
placeholder="Search aesthetics, games, styles..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded="false"
|
||||
aria-controls="tags-search-suggestions"
|
||||
data-tags-search-input
|
||||
class="h-12 w-full rounded-2xl border border-white/10 bg-black/25 pl-11 pr-4 text-sm text-white placeholder:text-white/30 focus:border-sky-400/45 focus:outline-none focus:ring-2 focus:ring-sky-400/20"
|
||||
>
|
||||
<div id="tags-search-suggestions" data-tags-search-panel class="absolute left-0 right-0 top-[calc(100%+0.75rem)] z-20 hidden overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(180deg,rgba(13,19,30,0.98),rgba(8,14,24,0.98))] shadow-[0_18px_50px_rgba(3,7,18,0.36)]">
|
||||
<div class="border-b border-white/8 px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.24em] text-white/38" data-tags-search-title>
|
||||
Suggested tags
|
||||
</div>
|
||||
<div class="max-h-72 overflow-y-auto p-2" data-tags-search-results role="listbox"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 sm:shrink-0">
|
||||
<button type="submit" class="inline-flex h-12 items-center justify-center rounded-2xl bg-sky-500 px-5 text-sm font-semibold text-slate-950 transition hover:bg-sky-400">
|
||||
Search
|
||||
</button>
|
||||
@if($query !== '')
|
||||
<a href="{{ route('tags.index') }}" class="inline-flex h-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] px-5 text-sm font-semibold text-white/72 transition hover:bg-white/[0.08] hover:text-white">
|
||||
Reset
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if($risingTags->isNotEmpty())
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">Popular right now</p>
|
||||
<p class="text-xs text-white/35">{{ $risingTags->count() }} quick jumps tuned by recent clicks</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2.5">
|
||||
@foreach($risingTags as $tag)
|
||||
<a href="{{ route('tags.show', $tag->slug) }}" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-3.5 py-2 text-sm text-white/72 transition hover:border-sky-400/30 hover:bg-sky-400/10 hover:text-white">
|
||||
<span class="inline-flex h-6 w-6 items-center justify-center rounded-full bg-white/[0.08] text-[11px] text-sky-200">#</span>
|
||||
<span>{{ $tag->name }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Active tags</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format($tagStats['active']) }}</p>
|
||||
<p class="mt-2 text-sm text-white/45">Browsable tags with active artwork associations.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Total usage</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format($tagStats['usage']) }}</p>
|
||||
<p class="mt-2 text-sm text-white/45">Tag assignments used across the catalog.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Matching now</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format($tagStats['matching']) }}</p>
|
||||
<p class="mt-2 text-sm text-white/45">
|
||||
{{ $query !== '' ? 'Results for your current search term.' : 'The current catalog available to browse.' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Recent clicks</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format((int) ($tagStats['recent_clicks'] ?? 0)) }}</p>
|
||||
<p class="mt-2 text-sm text-white/45">Last 14 days of tag discovery clicks used to tune highlights.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="rounded-[1.75rem] border border-white/[0.08] bg-white/[0.04] p-6 shadow-[0_16px_60px_rgba(3,7,18,0.22)] backdrop-blur-sm">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">Featured tag</p>
|
||||
<h3 class="mt-2 text-2xl font-semibold text-white">{{ $topFeaturedTag?->name ?? 'No featured tag yet' }}</h3>
|
||||
</div>
|
||||
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-sky-400/12 text-sky-200">
|
||||
<i class="fa-solid fa-wand-magic-sparkles text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($topFeaturedTag)
|
||||
<p class="mt-4 text-sm leading-6 text-white/58">
|
||||
One of the strongest tags in current discovery behavior, with {{ number_format($topFeaturedTag->artworks_count) }} artworks currently attached.
|
||||
</p>
|
||||
|
||||
<a href="{{ route('tags.show', $topFeaturedTag->slug) }}" class="mt-6 inline-flex items-center gap-2 rounded-2xl bg-white px-4 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-sky-100">
|
||||
Open #{{ $topFeaturedTag->slug }}
|
||||
<i class="fa-solid fa-arrow-right text-xs"></i>
|
||||
</a>
|
||||
@else
|
||||
<p class="mt-4 text-sm leading-6 text-white/58">Tag highlights will appear here as soon as the catalog has enough data.</p>
|
||||
@endif
|
||||
|
||||
<div class="mt-8 border-t border-white/10 pt-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">Browse tips</p>
|
||||
<div class="mt-4 space-y-3 text-sm text-white/56">
|
||||
<div class="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
Use broad tags like <span class="text-white">anime</span> or <span class="text-white">minimal</span> to start wide, then narrow down inside each feed.
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
Search matches both display names and slugs, so shorthand and full names work.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@if($featuredTags->isNotEmpty())
|
||||
<div class="mt-8 rounded-[1.75rem] border border-white/[0.08] bg-white/[0.03] p-6 md:p-7">
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">Editor's picks</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold text-white">High-signal tags worth exploring first</h2>
|
||||
</div>
|
||||
<p class="text-sm text-white/48">Ranked by recent discovery clicks first, then usage and artwork volume.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach($featuredTags as $index => $tag)
|
||||
<a href="{{ route('tags.show', $tag->slug) }}" class="group rounded-[1.35rem] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] p-5 transition duration-200 hover:-translate-y-0.5 hover:border-sky-400/30 hover:bg-sky-400/[0.08]">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span class="inline-flex h-9 min-w-9 items-center justify-center rounded-xl bg-sky-400/12 px-3 text-sm font-semibold text-sky-200">
|
||||
{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}
|
||||
</span>
|
||||
<span class="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/42">
|
||||
{{ number_format((int) ($tag->recent_clicks ?? 0)) }} recent clicks
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center gap-2 text-white">
|
||||
<i class="fa-solid fa-hashtag text-sky-300"></i>
|
||||
<h3 class="text-xl font-semibold tracking-tight">{{ $tag->name }}</h3>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-sm leading-6 text-white/56">
|
||||
{{ number_format($tag->artworks_count) }} artworks tagged. Open the feed to see the strongest matches first.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 inline-flex items-center gap-2 text-sm font-medium text-white/72 transition group-hover:text-white">
|
||||
Browse tag
|
||||
<i class="fa-solid fa-arrow-right text-xs transition group-hover:translate-x-0.5"></i>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-8 rounded-[1.75rem] border border-white/[0.08] bg-white/[0.02] p-6 md:p-7">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">All tags</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold text-white">
|
||||
{{ $query !== '' ? 'Search results' : 'Browse the full catalog' }}
|
||||
</h2>
|
||||
</div>
|
||||
<p class="text-sm text-white/46">
|
||||
Showing {{ number_format($tags->count()) }} of {{ number_format($tags->total()) }} tags.
|
||||
@if($query !== '')
|
||||
<span>Matching "{{ $query }}".</span>
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if($tags->isNotEmpty())
|
||||
<div class="mt-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
@foreach($tags as $tag)
|
||||
<a href="{{ route('tags.show', $tag->slug) }}" class="group flex min-h-[132px] flex-col justify-between rounded-[1.25rem] border border-white/[0.08] bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))] p-4 transition duration-200 hover:border-sky-400/28 hover:bg-sky-400/[0.07] hover:shadow-[0_16px_40px_rgba(14,165,233,0.08)]">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-white/[0.05] text-sky-300 transition group-hover:bg-sky-400/12">
|
||||
<i class="fa-solid fa-hashtag text-sm"></i>
|
||||
</div>
|
||||
<span class="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-white/38">
|
||||
{{ number_format($tag->artworks_count) }} artworks
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<h3 class="text-lg font-semibold tracking-tight text-white">{{ $tag->name }}</h3>
|
||||
<p class="mt-1 text-sm text-white/44">#{{ $tag->slug }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between text-sm text-white/58 transition group-hover:text-white/78">
|
||||
<span>{{ number_format($tag->usage_count) }} total uses</span>
|
||||
<i class="fa-solid fa-arrow-up-right-from-square text-xs"></i>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ $tags->withQueryString()->links() }}
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-6 rounded-[1.4rem] border border-dashed border-white/12 bg-black/20 px-8 py-12 text-center">
|
||||
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-white/[0.05] text-sky-300">
|
||||
<i class="fa-solid fa-tags text-xl"></i>
|
||||
</div>
|
||||
<h3 class="mt-5 text-xl font-semibold text-white">No tags matched this search</h3>
|
||||
<p class="mx-auto mt-2 max-w-xl text-sm leading-6 text-white/50">
|
||||
Try a broader keyword, remove punctuation, or reset the search to return to the full tag catalog.
|
||||
</p>
|
||||
<div class="mt-6 flex justify-center">
|
||||
<a href="{{ route('tags.index') }}" class="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/72 transition hover:bg-white/[0.08] hover:text-white">
|
||||
View all tags
|
||||
<i class="fa-solid fa-arrow-right text-xs"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p class="text-white/40 text-sm">No tags found.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
Reference in New Issue
Block a user