feat: Apple-style redesign of Tags pages (list/detail/form/delete)

- tag_list: Apple design tokens, big header + New Tag pill, Tag Cloud card
  (local echarts vendor assets, no CDN), segmented sort control, search with
  icon, stable per-name hash colors on # prefix, count badges, responsive grid
- tag_detail: Apple header card (#name + usage badge + Edit/Delete pills),
  segmented resource tabs (Links/Posts/Pages/Collections), unified res cards
- tag_form / tag_confirm_delete: Apple card + pill buttons, centered delete
  warning
This commit is contained in:
OpenClaw Sub-agent
2026-08-03 17:36:44 +10:00
parent 5acfd5f434
commit 53cf2cf1f9
4 changed files with 570 additions and 490 deletions
+51 -22
View File
@@ -1,30 +1,59 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="max-w-2xl mx-auto">
<div class="bg-white shadow rounded-lg p-6">
<h1 class="text-2xl font-bold mb-6">{% trans "Delete Tag" %}</h1>
<style>
:root {
--apple-red: #ff3b30; --apple-text: #1d1d1f; --apple-gray: #86868b;
--apple-radius-lg: 22px; --apple-ease: cubic-bezier(.25, .1, .25, 1);
}
.tags-page { max-width: 560px; margin: 0 auto; padding: 2rem 1.25rem 3rem; color: var(--apple-text); }
.apple-card {
background: #fff; border-radius: var(--apple-radius-lg);
border: 1px solid rgba(0, 0, 0, 0.05); box-shadow: 0 4px 18px rgba(0, 0, 0, 0.05);
padding: 1.6rem 1.6rem 1.8rem; text-align: center;
}
.warn-icon {
width: 3rem; height: 3rem; margin: 0 auto 1rem; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: rgba(255, 59, 48, 0.12); color: var(--apple-red);
}
.warn-icon svg { width: 1.4rem; height: 1.4rem; }
.apple-card h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 0.6rem; }
.apple-card p.desc { color: var(--apple-gray); font-size: 0.9rem; margin: 0 0 1.6rem; line-height: 1.5; }
.apple-card p.desc strong { color: var(--apple-text); }
.form-actions { display: flex; justify-content: center; gap: 0.55rem; }
.apple-btn {
display: inline-flex; align-items: center; min-height: 38px; padding: 0.45rem 1.15rem;
border-radius: 980px; font-size: 0.85rem; font-weight: 600; text-decoration: none;
border: none; cursor: pointer; transition: background 0.25s ease, transform 0.2s var(--apple-ease);
}
.apple-btn-secondary { background: #f0f0f3; color: var(--apple-text); }
.apple-btn-secondary:hover { background: #e5e5ea; }
.apple-btn-danger { background: var(--apple-red); color: #fff; box-shadow: 0 8px 22px rgba(255, 59, 48, 0.3); }
.apple-btn-danger:hover { background: #ff5147; }
.apple-btn:active { transform: scale(0.96); }
</style>
<p class="mb-6 text-gray-600">
{% blocktrans with tag_name=tag.name %}
Are you sure you want to delete the tag "{{ tag_name }}"? This action cannot be undone.
{% endblocktrans %}
</p>
<form method="post" class="space-y-6">
{% csrf_token %}
<div class="flex justify-end space-x-3">
<a href="{% url 'tag-detail' tag.slug %}" class="bg-gray-200 hover:bg-gray-300 text-gray-700 font-bold py-2 px-4 rounded">
{% trans "Cancel" %}
</a>
<button type="submit" class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded">
{% trans "Delete" %}
</button>
</div>
</form>
<div class="tags-page">
<div class="apple-card">
<div class="warn-icon" aria-hidden="true">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
</svg>
</div>
<h1>{% trans "Delete Tag" %}</h1>
<p class="desc">
{% blocktrans with tag_name=tag.name %}
Are you sure you want to delete the tag <strong>"{{ tag_name }}"</strong>? This action cannot be undone.
{% endblocktrans %}
</p>
<form method="post">
{% csrf_token %}
<div class="form-actions">
<a href="{% url 'tag-detail' tag.slug %}" class="apple-btn apple-btn-secondary">{% trans "Cancel" %}</a>
<button type="submit" class="apple-btn apple-btn-danger">{% trans "Delete" %}</button>
</div>
</form>
</div>
</div>
{% endblock %}
+245 -204
View File
@@ -1,218 +1,259 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="container mx-auto px-2 py-8">
<div class="bg-white shadow-lg rounded-lg overflow-hidden">
<!-- Tag Header -->
<div class="bg-gradient-to-r from-blue-500 to-indigo-600 p-6 text-white">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold mb-2">{{ tag.name }}</h1>
{% if tag.description %}
<p class="text-white text-opacity-90 mb-2">{{ tag.description }}</p>
{% endif %}
<div class="inline-flex items-center bg-white bg-opacity-20 px-3 py-1 rounded-full text-sm">
<span class="font-medium">{% trans "Used" %}: {{ total_usage_count }} {% trans "times" %}</span>
</div>
</div>
<div class="flex space-x-2">
<a href="{% url 'tag-update' tag.slug %}" class="bg-white text-blue-600 hover:bg-blue-50 font-medium py-2 px-2 rounded-lg transition duration-150 ease-in-out">
{% trans "Edit" %}
</a>
<a href="{% url 'tag-delete' tag.slug %}" class="bg-red-500 hover:bg-red-600 text-white font-medium py-2 px-2 rounded-lg transition duration-150 ease-in-out">
{% trans "Delete" %}
</a>
</div>
<style>
/* ── Apple design tokens (same system as Files / Collections / Tags list) ── */
:root {
--apple-blue: #0071e3; --apple-red: #ff3b30; --apple-text: #1d1d1f;
--apple-gray: #86868b; --apple-separator: rgba(0, 0, 0, 0.06);
--apple-bg: #f5f5f7; --apple-radius-lg: 22px;
--apple-ease: cubic-bezier(.25, .1, .25, 1);
--apple-shadow-lift: 0 18px 44px rgba(0, 0, 0, 0.12);
}
.tags-page {
max-width: 1080px; margin: 0 auto; padding: 2rem 1.25rem 3rem;
color: var(--apple-text);
}
.apple-card {
background: #fff; border-radius: var(--apple-radius-lg);
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.05);
padding: 1.5rem; margin-bottom: 1.25rem;
}
/* ── Tag header ── */
.tag-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.tag-header h1 {
display: flex; align-items: center; gap: 0.7rem; flex-wrap: wrap;
font-size: 2rem; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 0.5rem;
}
.tag-hash { color: var(--apple-blue); }
.tag-desc { color: var(--apple-gray); font-size: 0.95rem; margin: 0 0 0.9rem; }
.tag-usage {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.32rem 0.85rem; border-radius: 980px;
background: rgba(0, 113, 227, 0.1); color: var(--apple-blue);
font-size: 0.78rem; font-weight: 700;
}
.tag-actions { display: flex; gap: 0.55rem; flex: none; }
.apple-btn {
display: inline-flex; align-items: center; gap: 0.4rem;
min-height: 38px; padding: 0.45rem 1.05rem; border-radius: 980px;
font-size: 0.85rem; font-weight: 600; text-decoration: none; border: none; cursor: pointer;
transition: background 0.25s ease, transform 0.2s var(--apple-ease), box-shadow 0.25s ease;
}
.apple-btn-secondary { background: #fff; color: var(--apple-text); border: 1px solid rgba(0, 0, 0, 0.12); }
.apple-btn-secondary:hover { background: #f0f0f3; }
.apple-btn-danger { background: var(--apple-red); color: #fff; box-shadow: 0 8px 22px rgba(255, 59, 48, 0.3); }
.apple-btn-danger:hover { background: #ff5147; }
.apple-btn:active { transform: scale(0.96); }
/* ── Segmented tabs ── */
.seg-tabs {
display: inline-flex; padding: 3px; gap: 2px;
background: rgba(0, 0, 0, 0.055); border-radius: 12px;
margin-bottom: 1.4rem; flex-wrap: wrap;
}
.seg-tab {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.45rem 0.95rem; border-radius: 9px; border: none; cursor: pointer;
background: transparent; font-size: 0.82rem; font-weight: 600; color: var(--apple-text);
transition: background 0.2s ease, box-shadow 0.2s ease;
}
.seg-tab:hover { background: rgba(0, 0, 0, 0.05); }
.seg-tab.active { background: #fff; box-shadow: 0 1px 5px rgba(0, 0, 0, 0.12); }
.seg-tab .tab-count {
display: inline-flex; align-items: center; justify-content: center;
min-width: 1.35rem; height: 1.15rem; padding: 0 0.35rem;
border-radius: 980px; font-size: 0.68rem; font-weight: 700;
background: rgba(0, 0, 0, 0.07); color: var(--apple-gray);
}
.seg-tab.active .tab-count { background: rgba(0, 113, 227, 0.14); color: var(--apple-blue); }
/* ── Resource cards ── */
.res-grid { display: grid; gap: 0.7rem; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.res-card {
display: block; background: #fff; border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 16px; padding: 1rem 1.1rem; text-decoration: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s var(--apple-ease);
}
.res-card:hover {
border-color: rgba(0, 113, 227, 0.35);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
}
.res-card h3 { font-size: 0.95rem; font-weight: 700; margin: 0 0 0.3rem; color: var(--apple-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.res-card h3 a { color: var(--apple-blue); text-decoration: none; }
.res-card h3 a:hover { text-decoration: underline; }
.res-card .res-desc { font-size: 0.8rem; color: var(--apple-gray); margin: 0 0 0.55rem; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.res-card .res-meta { font-size: 0.72rem; color: var(--apple-gray); display: flex; align-items: center; gap: 0.3rem; }
.res-card .res-meta svg { width: 0.85rem; height: 0.85rem; flex: none; }
.res-empty {
padding: 2.5rem 1rem; text-align: center; color: var(--apple-gray); font-size: 0.9rem;
background: #fff; border: 1px dashed rgba(0, 0, 0, 0.12); border-radius: 16px;
}
@media (max-width: 640px) {
.tags-page { padding: 1.35rem 1rem 2.5rem; }
.tag-header h1 { font-size: 1.6rem; }
.res-grid { grid-template-columns: 1fr; }
}
</style>
<div class="tags-page">
<div class="apple-card">
<!-- Header -->
<div class="tag-header">
<div style="min-width:0">
<h1><span class="tag-hash" aria-hidden="true">#</span><span style="overflow-wrap:anywhere">{{ tag.name }}</span></h1>
{% if tag.description %}<p class="tag-desc">{{ tag.description }}</p>{% endif %}
<span class="tag-usage">
<svg width="13" height="13" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
{% trans "Used" %} {{ total_usage_count }} {% trans "times" %}
</span>
</div>
</div>
<!-- Resource Tabs -->
<div class="border-b border-gray-200">
<nav class="flex -mb-px overflow-x-auto" aria-label="Tabs">
<button class="resource-tab text-blue-600 border-blue-500 whitespace-nowrap py-2 px-6 border-b-2 font-medium text-sm" data-target="links-section">
{% trans "Links" %} ({{ links|length }})
</button>
<button class="resource-tab text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-6 border-b-2 border-transparent font-medium text-sm" data-target="posts-section">
{% trans "Posts" %} ({{ posts|length }})
</button>
<button class="resource-tab text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-6 border-b-2 border-transparent font-medium text-sm" data-target="pages-section">
{% trans "Pages" %} ({{ pages|length }})
</button>
<button class="resource-tab text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-6 border-b-2 border-transparent font-medium text-sm" data-target="collections-section">
{% trans "Image Collections" %} ({{ image_collections|length }})
</button>
</nav>
</div>
<!-- Resource Sections -->
<div class="p-6">
<!-- Links Section -->
<div id="links-section" class="resource-section">
<h2 class="text-xl font-semibold mb-4 flex items-center">
{% trans "Links with this tag" %}
<span class="ml-2 bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">{{ links|length }}</span>
</h2>
{% if links %}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{% for link in links %}
<div class="border border-gray-200 rounded-lg p-4 hover:shadow-md transition duration-150 ease-in-out">
<h3 class="font-medium text-lg">
<a href="{% url 'link_detail' pk=link.pk %}" class="text-blue-600 hover:text-blue-800">
{{ link.alias }}
</a>
</h3>
{% if link.description %}
<p class="text-gray-600 mt-1 line-clamp-2">{{ link.description }}</p>
{% endif %}
<div class="mt-2 text-sm text-gray-500 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{{ link.created_at|date:"Y-m-d" }}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="bg-gray-50 rounded-lg p-6 text-center">
<p class="text-gray-500">{% trans "No links found with this tag." %}</p>
</div>
{% endif %}
</div>
<!-- Posts Section -->
<div id="posts-section" class="resource-section hidden">
<h2 class="text-xl font-semibold mb-4 flex items-center">
{% trans "Posts with this tag" %}
<span class="ml-2 bg-green-100 text-green-800 text-xs font-medium px-2.5 py-0.5 rounded-full">{{ posts|length }}</span>
</h2>
{% if posts %}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{% for post in posts %}
<div class="border border-gray-200 rounded-lg p-4 hover:shadow-md transition duration-150 ease-in-out">
<h3 class="font-medium text-lg">
<a href="{% url 'post-detail' pk=post.pk %}" class="text-blue-600 hover:text-blue-800">
{{ post.title }}
</a>
</h3>
{% if post.summary %}
<p class="text-gray-600 mt-1 line-clamp-2">{{ post.summary }}</p>
{% endif %}
<div class="mt-2 text-sm text-gray-500 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{{ post.created_at|date:"Y-m-d" }}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="bg-gray-50 rounded-lg p-6 text-center">
<p class="text-gray-500">{% trans "No posts found with this tag." %}</p>
</div>
{% endif %}
</div>
<!-- Pages Section -->
<div id="pages-section" class="resource-section hidden">
<h2 class="text-xl font-semibold mb-4 flex items-center">
{% trans "Pages with this tag" %}
<span class="ml-2 bg-purple-100 text-purple-800 text-xs font-medium px-2.5 py-0.5 rounded-full">{{ pages|length }}</span>
</h2>
{% if pages %}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{% for page in pages %}
<div class="border border-gray-200 rounded-lg p-4 hover:shadow-md transition duration-150 ease-in-out">
<h3 class="font-medium text-lg">
<a href="{% url 'page-detail' pk=page.pk %}" class="text-blue-600 hover:text-blue-800">
{{ page.title|default:page.url }}
</a>
</h3>
{% if page.summary %}
<p class="text-gray-600 mt-1 line-clamp-2">{{ page.summary }}</p>
{% endif %}
<div class="mt-2 text-sm text-gray-500 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{{ page.created_at|date:"Y-m-d" }}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="bg-gray-50 rounded-lg p-6 text-center">
<p class="text-gray-500">{% trans "No pages found with this tag." %}</p>
</div>
{% endif %}
</div>
<!-- Image Collections Section -->
<div id="collections-section" class="resource-section hidden">
<h2 class="text-xl font-semibold mb-4 flex items-center">
{% trans "Image Collections with this tag" %}
<span class="ml-2 bg-yellow-100 text-yellow-800 text-xs font-medium px-2.5 py-0.5 rounded-full">{{ image_collections|length }}</span>
</h2>
{% if image_collections %}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{% for collection in image_collections %}
<div class="border border-gray-200 rounded-lg p-4 hover:shadow-md transition duration-150 ease-in-out">
<h3 class="font-medium text-lg">
<a href="{% url 'collection-detail' pk=collection.pk %}" class="text-blue-600 hover:text-blue-800">
{{ collection.name }}
</a>
</h3>
{% if collection.description %}
<p class="text-gray-600 mt-1 line-clamp-2">{{ collection.description }}</p>
{% endif %}
<div class="mt-2 text-sm text-gray-500 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{{ collection.created_at|date:"Y-m-d" }}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="bg-gray-50 rounded-lg p-6 text-center">
<p class="text-gray-500">{% trans "No image collections found with this tag." %}</p>
</div>
{% endif %}
<div class="tag-actions">
<a href="{% url 'tag-update' tag.slug %}" class="apple-btn apple-btn-secondary">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125"/>
</svg>
{% trans "Edit" %}
</a>
<a href="{% url 'tag-delete' tag.slug %}" class="apple-btn apple-btn-danger">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
{% trans "Delete" %}
</a>
</div>
</div>
</div>
<!-- Segmented tabs -->
<div class="seg-tabs" role="tablist" aria-label="{% trans 'Tagged resources' %}">
<button type="button" class="seg-tab active" data-target="links-section" role="tab" aria-selected="true">
{% trans "Links" %}<span class="tab-count">{{ links|length }}</span>
</button>
<button type="button" class="seg-tab" data-target="posts-section" role="tab" aria-selected="false">
{% trans "Posts" %}<span class="tab-count">{{ posts|length }}</span>
</button>
<button type="button" class="seg-tab" data-target="pages-section" role="tab" aria-selected="false">
{% trans "Pages" %}<span class="tab-count">{{ pages|length }}</span>
</button>
<button type="button" class="seg-tab" data-target="collections-section" role="tab" aria-selected="false">
{% trans "Collections" %}<span class="tab-count">{{ image_collections|length }}</span>
</button>
</div>
<!-- Links -->
<div id="links-section" class="resource-section apple-card">
<h2 style="font-size:1rem;font-weight:700;margin:0 0 1rem">{% trans "Links with this tag" %}</h2>
{% if links %}
<div class="res-grid">
{% for link in links %}
<a href="{% url 'link_detail' pk=link.pk %}" class="res-card">
<h3>{{ link.alias }}</h3>
{% if link.description %}<p class="res-desc">{{ link.description }}</p>{% endif %}
<p class="res-meta">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
{{ link.created_at|date:"Y-m-d" }}
</p>
</a>
{% endfor %}
</div>
{% else %}
<div class="res-empty">{% trans "No links found with this tag." %}</div>
{% endif %}
</div>
<!-- Posts -->
<div id="posts-section" class="resource-section apple-card" hidden>
<h2 style="font-size:1rem;font-weight:700;margin:0 0 1rem">{% trans "Posts with this tag" %}</h2>
{% if posts %}
<div class="res-grid">
{% for post in posts %}
<a href="{% url 'post-detail' pk=post.pk %}" class="res-card">
<h3>{{ post.title }}</h3>
{% if post.summary %}<p class="res-desc">{{ post.summary }}</p>{% endif %}
<p class="res-meta">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
{{ post.created_at|date:"Y-m-d" }}
</p>
</a>
{% endfor %}
</div>
{% else %}
<div class="res-empty">{% trans "No posts found with this tag." %}</div>
{% endif %}
</div>
<!-- Pages -->
<div id="pages-section" class="resource-section apple-card" hidden>
<h2 style="font-size:1rem;font-weight:700;margin:0 0 1rem">{% trans "Pages with this tag" %}</h2>
{% if pages %}
<div class="res-grid">
{% for page in pages %}
<a href="{% url 'page-detail' pk=page.pk %}" class="res-card">
<h3>{{ page.title|default:page.url }}</h3>
{% if page.summary %}<p class="res-desc">{{ page.summary }}</p>{% endif %}
<p class="res-meta">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
{{ page.created_at|date:"Y-m-d" }}
</p>
</a>
{% endfor %}
</div>
{% else %}
<div class="res-empty">{% trans "No pages found with this tag." %}</div>
{% endif %}
</div>
<!-- Image Collections -->
<div id="collections-section" class="resource-section apple-card" hidden>
<h2 style="font-size:1rem;font-weight:700;margin:0 0 1rem">{% trans "Image Collections with this tag" %}</h2>
{% if image_collections %}
<div class="res-grid">
{% for collection in image_collections %}
<a href="{% url 'collection-detail' pk=collection.pk %}" class="res-card">
<h3>{{ collection.name }}</h3>
{% if collection.description %}<p class="res-desc">{{ collection.description }}</p>{% endif %}
<p class="res-meta">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
{{ collection.created_at|date:"Y-m-d" }}
</p>
</a>
{% endfor %}
</div>
{% else %}
<div class="res-empty">{% trans "No image collections found with this tag." %}</div>
{% endif %}
</div>
</div>
<!-- JavaScript for tab functionality -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const tabs = document.querySelectorAll('.resource-tab');
const sections = document.querySelectorAll('.resource-section');
tabs.forEach(tab => {
tab.addEventListener('click', function() {
// Hide all sections
sections.forEach(section => {
section.classList.add('hidden');
});
// Show the selected section
const targetId = this.getAttribute('data-target');
document.getElementById(targetId).classList.remove('hidden');
// Update tab styles
tabs.forEach(t => {
t.classList.remove('text-blue-600', 'border-blue-500');
t.classList.add('text-gray-500', 'border-transparent');
});
this.classList.remove('text-gray-500', 'border-transparent');
this.classList.add('text-blue-600', 'border-blue-500');
});
document.addEventListener('DOMContentLoaded', function () {
const tabs = document.querySelectorAll('.seg-tab');
const sections = document.querySelectorAll('.resource-section');
tabs.forEach(tab => {
tab.addEventListener('click', function () {
sections.forEach(s => { s.hidden = true; });
const target = document.getElementById(this.getAttribute('data-target'));
if (target) target.hidden = false;
tabs.forEach(t => { t.classList.remove('active'); t.setAttribute('aria-selected', 'false'); });
this.classList.add('active');
this.setAttribute('aria-selected', 'true');
});
});
});
</script>
{% endblock %}
+60 -52
View File
@@ -1,60 +1,68 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="bg-white shadow-md rounded-lg overflow-hidden">
<div class="px-4 py-5 sm:px-6 bg-gray-50 border-b border-gray-200">
<h1 class="text-2xl font-bold text-gray-900">
{% if form.instance.pk %}
{% trans "Edit Tag" %}
<style>
:root {
--apple-blue: #0071e3; --apple-text: #1d1d1f; --apple-gray: #86868b;
--apple-separator: rgba(0, 0, 0, 0.06); --apple-radius-lg: 22px;
--apple-ease: cubic-bezier(.25, .1, .25, 1);
}
.tags-page { max-width: 640px; margin: 0 auto; padding: 2rem 1.25rem 3rem; color: var(--apple-text); }
.apple-card {
background: #fff; border-radius: var(--apple-radius-lg);
border: 1px solid rgba(0, 0, 0, 0.05); box-shadow: 0 4px 18px rgba(0, 0, 0, 0.05);
padding: 1.6rem 1.6rem 1.8rem;
}
.apple-card h1 { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 1.3rem; }
.field { margin-bottom: 1.15rem; }
.field label { display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.45rem; }
.field input, .field textarea {
width: 100%; border: 1px solid rgba(0, 0, 0, 0.12); border-radius: 12px;
padding: 0.65rem 0.9rem; font-size: 0.9rem; font-family: inherit; color: var(--apple-text);
background: var(--apple-bg); transition: border-color 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
box-sizing: border-box;
}
.field input:focus, .field textarea:focus {
outline: none; background: #fff; border-color: rgba(0, 113, 227, 0.5);
box-shadow: 0 0 0 4px rgba(0, 113, 227, 0.14);
}
.field .help { font-size: 0.78rem; color: var(--apple-gray); margin-top: 0.35rem; }
.field .errors { color: #ff3b30; font-size: 0.8rem; margin-top: 0.35rem; }
.form-actions { display: flex; justify-content: flex-end; gap: 0.55rem; margin-top: 1.6rem; }
.apple-btn {
display: inline-flex; align-items: center; min-height: 38px; padding: 0.45rem 1.1rem;
border-radius: 980px; font-size: 0.85rem; font-weight: 600; text-decoration: none;
border: none; cursor: pointer; transition: background 0.25s ease, transform 0.2s var(--apple-ease);
}
.apple-btn-secondary { background: #f0f0f3; color: var(--apple-text); }
.apple-btn-secondary:hover { background: #e5e5ea; }
.apple-btn-primary { background: var(--apple-blue); color: #fff; box-shadow: 0 8px 22px rgba(0, 113, 227, 0.3); }
.apple-btn-primary:hover { background: #2487e6; }
.apple-btn:active { transform: scale(0.96); }
</style>
<div class="tags-page">
<div class="apple-card">
<h1>{% if form.instance.pk %}{% trans "Edit Tag" %}{% else %}{% trans "New Tag" %}{% endif %}</h1>
<form method="post">
{% csrf_token %}
{% for field in form %}
<div class="field">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{% if field.name == 'description' %}
<textarea name="{{ field.name }}" id="{{ field.id_for_label }}" rows="3">{{ field.value|default:'' }}</textarea>
{% else %}
{% trans "New Tag" %}
<input type="text" name="{{ field.name }}" id="{{ field.id_for_label }}" value="{{ field.value|default:'' }}">
{% endif %}
</h1>
</div>
<div class="px-4 py-5 sm:p-6">
<form method="post" class="space-y-6">
{% csrf_token %}
{% for field in form %}
<div class="space-y-2">
<label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-700">
{{ field.label }}
</label>
<div class="mt-1">
{% if field.name == 'description' %}
<textarea name="{{ field.name }}" id="{{ field.id_for_label }}"
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
rows="3">{{ field.value|default:'' }}</textarea>
{% else %}
<input type="text" name="{{ field.name }}" id="{{ field.id_for_label }}"
value="{{ field.value|default:'' }}"
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md">
{% endif %}
</div>
{% if field.help_text %}
<p class="mt-2 text-sm text-gray-500">{{ field.help_text }}</p>
{% endif %}
{% if field.errors %}
<div class="mt-2 text-sm text-red-600">
{{ field.errors }}
</div>
{% endif %}
</div>
{% endfor %}
<div class="flex justify-end space-x-3 mt-6">
<a href="{% url 'tag-list' %}" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-gray-700 bg-gray-200 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">
{% trans "Cancel" %}
</a>
<button type="submit" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
{% trans "Save" %}
</button>
</div>
</form>
</div>
{% if field.help_text %}<p class="help">{{ field.help_text }}</p>{% endif %}
{% if field.errors %}<p class="errors">{{ field.errors }}</p>{% endif %}
</div>
{% endfor %}
<div class="form-actions">
<a href="{% url 'tag-list' %}" class="apple-btn apple-btn-secondary">{% trans "Cancel" %}</a>
<button type="submit" class="apple-btn apple-btn-primary">{% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% endblock %}
+214 -212
View File
@@ -1,240 +1,242 @@
{% extends "base.html" %}
{% load i18n %}
{% load i18n static %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{% trans "Tags" %}</h1>
<a href="{% url 'tag-create' %}" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
<style>
/* ── Apple design tokens (same system as Files / Collections) ── */
:root {
--apple-blue: #0071e3; --apple-green: #27ae60; --apple-text: #1d1d1f;
--apple-gray: #86868b; --apple-separator: rgba(0, 0, 0, 0.06);
--apple-bg: #f5f5f7; --apple-radius-lg: 22px;
--apple-ease: cubic-bezier(.25, .1, .25, 1);
--apple-shadow-lift: 0 18px 44px rgba(0, 0, 0, 0.12);
}
.tags-page {
max-width: 1080px; margin: 0 auto; padding: 2rem 1.25rem 3rem;
color: var(--apple-text);
}
/* ── Header ── */
.tags-header {
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
margin-bottom: 1.4rem;
}
.tags-header h1 {
font-size: 2.1rem; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 0.2rem;
}
.tags-header .count { font-size: 0.9rem; color: var(--apple-gray); margin: 0; }
.apple-btn {
display: inline-flex; align-items: center; gap: 0.45rem;
min-height: 40px; padding: 0.5rem 1.15rem; border-radius: 980px;
font-size: 0.875rem; font-weight: 600; text-decoration: none; border: none; cursor: pointer;
transition: background 0.25s ease, transform 0.2s var(--apple-ease), box-shadow 0.25s ease;
}
.apple-btn-primary { background: var(--apple-blue); color: #fff; box-shadow: 0 8px 22px rgba(0, 113, 227, 0.32); }
.apple-btn-primary:hover { background: #2487e6; }
.apple-btn-primary:active { transform: scale(0.96); }
/* ── Apple card ── */
.apple-card {
background: #fff; border-radius: var(--apple-radius-lg);
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.05);
padding: 1.4rem 1.5rem; margin-bottom: 1.25rem;
}
.apple-card h2 { font-size: 1.05rem; font-weight: 700; margin: 0 0 1rem; }
/* ── Word cloud ── */
#tag-cloud-container { width: 100%; height: 380px; }
@media (max-width: 640px) { #tag-cloud-container { height: 300px; } }
/* ── Toolbar: sort segmented + search ── */
.tags-toolbar {
display: flex; align-items: center; justify-content: space-between; gap: 0.9rem;
margin-bottom: 1.1rem; flex-wrap: wrap;
}
.sort-segmented {
display: inline-flex; padding: 3px; gap: 2px;
background: rgba(0, 0, 0, 0.055); border-radius: 12px;
}
.sort-btn {
padding: 0.42rem 0.85rem; border-radius: 9px;
font-size: 0.8rem; font-weight: 600; color: var(--apple-text);
text-decoration: none; white-space: nowrap;
transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.sort-btn:hover { background: rgba(0, 0, 0, 0.05); }
.sort-btn.active { background: #fff; color: var(--apple-text); box-shadow: 0 1px 5px rgba(0, 0, 0, 0.12); }
.tags-search { position: relative; flex: 1; min-width: 220px; max-width: 300px; }
.tags-search svg {
position: absolute; left: 0.85rem; top: 50%; transform: translateY(-50%);
width: 1rem; height: 1rem; color: var(--apple-gray); pointer-events: none;
}
.tags-search input {
width: 100%; height: 40px; padding: 0 1rem 0 2.35rem;
border: 1px solid rgba(0, 0, 0, 0.07); border-radius: 12px;
background: #fff; font-size: 0.875rem; color: var(--apple-text);
transition: border-color 0.25s ease, box-shadow 0.25s ease;
}
.tags-search input::placeholder { color: var(--apple-gray); }
.tags-search input:focus {
outline: none; border-color: rgba(0, 113, 227, 0.45);
box-shadow: 0 0 0 4px rgba(0, 113, 227, 0.12);
}
/* ── Tag grid ── */
.tags-grid {
display: grid; gap: 0.7rem;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.tag-card {
display: flex; align-items: center; gap: 0.6rem;
background: #fff; border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 16px; padding: 0.8rem 1rem;
text-decoration: none; min-width: 0;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s var(--apple-ease);
}
.tag-card:hover {
border-color: rgba(0, 113, 227, 0.35);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
}
.tag-hash { font-size: 1.05rem; font-weight: 800; flex: none; }
.tag-name {
font-size: 0.9rem; font-weight: 600; color: var(--apple-text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;
}
.tag-count {
flex: none; display: inline-flex; align-items: center;
min-width: 1.9rem; height: 1.5rem; padding: 0 0.5rem; justify-content: center;
border-radius: 980px; font-size: 0.72rem; font-weight: 700;
background: rgba(0, 0, 0, 0.055); color: var(--apple-gray);
}
.no-results { display: none; text-align: center; padding: 2.5rem 1rem; color: var(--apple-gray); font-size: 0.9rem; }
@media (max-width: 640px) {
.tags-page { padding: 1.35rem 1rem 2.5rem; }
.tags-header h1 { font-size: 1.7rem; }
.tags-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.55rem; }
.tag-card { padding: 0.7rem 0.8rem; }
.sort-segmented { overflow-x: auto; max-width: 100%; }
.tags-search { max-width: 100%; }
}
</style>
<div class="tags-page">
<!-- Header -->
<div class="tags-header">
<div>
<h1>{% trans "Tags" %}</h1>
<p class="count">{{ tags|length }} {% trans "tags" %} · {% trans "click the cloud or a card to explore" %}</p>
</div>
<a href="{% url 'tag-create' %}" class="apple-btn apple-btn-primary">
<svg width="15" height="15" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14m-7-7h14"/>
</svg>
{% trans "New Tag" %}
</a>
</div>
<!-- Tag Cloud Section -->
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 class="text-xl font-semibold mb-4">{% trans "Tag Cloud" %}</h2>
<div id="tag-cloud-container" style="width: 100%; height: 400px;"></div>
<!-- Tag Cloud (local echarts, no CDN) -->
<div class="apple-card">
<h2>{% trans "Tag Cloud" %}</h2>
<div id="tag-cloud-container"></div>
</div>
<!-- Sort Controls and Filter -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div class="flex flex-wrap gap-2">
<span class="text-gray-700 font-medium self-center mr-2">{% trans "Sort by:" %}</span>
<a href="?sort=name" class="sort-btn px-3 py-1 rounded text-sm {% if current_sort == 'name' %}bg-blue-500 text-white{% else %}bg-gray-200 text-gray-700 hover:bg-gray-300{% endif %}">
{% trans "Name (A-Z)" %}
</a>
<a href="?sort=name_desc" class="sort-btn px-3 py-1 rounded text-sm {% if current_sort == 'name_desc' %}bg-blue-500 text-white{% else %}bg-gray-200 text-gray-700 hover:bg-gray-300{% endif %}">
{% trans "Name (Z-A)" %}
</a>
<a href="?sort=count" class="sort-btn px-3 py-1 rounded text-sm {% if current_sort == 'count' %}bg-blue-500 text-white{% else %}bg-gray-200 text-gray-700 hover:bg-gray-300{% endif %}">
{% trans "Most Used" %}
</a>
<a href="?sort=count_asc" class="sort-btn px-3 py-1 rounded text-sm {% if current_sort == 'count_asc' %}bg-blue-500 text-white{% else %}bg-gray-200 text-gray-700 hover:bg-gray-300{% endif %}">
{% trans "Least Used" %}
</a>
<!-- Sort + filter -->
<div class="tags-toolbar">
<div class="sort-segmented" role="group" aria-label="{% trans 'Sort tags' %}">
<a href="?sort=name" class="sort-btn {% if current_sort == 'name' %}active{% endif %}">{% trans "Name AZ" %}</a>
<a href="?sort=name_desc" class="sort-btn {% if current_sort == 'name_desc' %}active{% endif %}">{% trans "ZA" %}</a>
<a href="?sort=count" class="sort-btn {% if current_sort == 'count' %}active{% endif %}">{% trans "Most Used" %}</a>
<a href="?sort=count_asc" class="sort-btn {% if current_sort == 'count_asc' %}active{% endif %}">{% trans "Least Used" %}</a>
</div>
<!-- Quick Filter Input -->
<div class="w-full sm:w-64 relative">
<input type="text" id="filter-tags-input" placeholder="{% trans 'Filter tags...' %}"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<svg class="h-4 w-4 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
</svg>
</div>
<div class="tags-search">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-4.35-4.35M17 10.5a6.5 6.5 0 11-13 0 6.5 6.5 0 0113 0z"/>
</svg>
<input type="text" id="filter-tags-input" placeholder="{% trans 'Filter tags…' %}" autocomplete="off">
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4" id="tags-grid">
<!-- Tag cards -->
<div class="tags-grid" id="tags-grid">
{% for tag in tags %}
<a href="{% url 'tag-detail' tag.slug %}" class="block group tag-item" data-tag-name="{{ tag.name|lower }}">
<div
class="tag-card rounded-lg p-2 h-full flex flex-col items-center justify-center text-center shadow-md hover:shadow-lg transition-all duration-200">
<div class="mb-2">
{% with icon_classes="w-8 h-8 mx-auto" %}
{% if forloop.counter|divisibleby:5 %}
<svg class="{{ icon_classes }}" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
{% elif forloop.counter|divisibleby:4 %}
<svg class="{{ icon_classes }}" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{% elif forloop.counter|divisibleby:3 %}
<svg class="{{ icon_classes }}" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{% elif forloop.counter|divisibleby:2 %}
<svg class="{{ icon_classes }}" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
</svg>
{% else %}
<svg class="{{ icon_classes }}" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
{% endif %}
{% endwith %}
</div>
<h2 class="text-lg font-semibold mb-1">{{ tag.name }}</h2>
<div class="mt-1">
<span
class="inline-flex items-center justify-center bg-white bg-opacity-30 text-xs font-medium px-2 py-1 rounded-full">
{{ tag.total_count }} {% trans "uses" %}
</span>
</div>
</div>
<a href="{% url 'tag-detail' tag.slug %}" class="tag-card tag-item" data-tag-name="{{ tag.name|lower }}">
<span class="tag-hash" aria-hidden="true">#</span>
<span class="tag-name" title="{{ tag.name }}">{{ tag.name }}</span>
<span class="tag-count">{{ tag.total_count }}</span>
</a>
{% empty %}
<div class="col-span-full text-center py-8 text-gray-500">
{% trans "No tags found." %}
</div>
<div class="no-results" style="display:block;grid-column:1/-1">{% trans "No tags found." %}</div>
{% endfor %}
</div>
<!-- No results message (hidden by default) -->
<div id="no-results-message" class="hidden col-span-full text-center py-8 text-gray-500">
{% trans "No matching tags found." %}
</div>
<div id="no-results-message" class="no-results">{% trans "No matching tags found." %}</div>
</div>
<!-- ECharts (CDN — page-specific, benefits from edge proximity) -->
<script src="https://cdn.jsdelivr.net/npm/echarts@5.6.0/dist/echarts.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts-wordcloud@2.1.0/dist/echarts-wordcloud.min.js"></script>
<script src="{% static 'vendor/echarts/echarts.min.js' %}"></script>
<script src="{% static 'vendor/echarts/echarts-wordcloud.min.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Define colors for tags - using colors from the image
const tagColors = [
'#3498db', // blue
'#2ecc71', // green
'#e74c3c', // red
'#f39c12', // orange
'#9b59b6', // purple
'#1abc9c', // teal
'#d35400', // dark orange
'#34495e', // navy
'#16a085', // dark green
'#27ae60', // medium green
'#8e44ad', // violet
'#f1c40f' // yellow
];
document.addEventListener('DOMContentLoaded', function () {
const tagColors = [
'#3498db', '#2ecc71', '#e74c3c', '#f39c12', '#9b59b6',
'#1abc9c', '#d35400', '#16a085', '#8e44ad', '#e91e63'
];
// Stable per-name colour (hash) so cards don't repaint on every load
const colorOf = name => tagColors[Math.abs([...name].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7)) % tagColors.length];
document.querySelectorAll('.tag-item').forEach(card => {
const name = card.dataset.tagName;
card.querySelector('.tag-hash').style.color = colorOf(name || 'x');
});
// Apply colors to tag cards
const tagCards = document.querySelectorAll('.tag-card');
tagCards.forEach((card, index) => {
const randomColor = tagColors[Math.floor(Math.random() * tagColors.length)];
card.style.backgroundColor = randomColor;
card.style.color = 'white';
});
// Initialize ECharts instance
const chartDom = document.getElementById('tag-cloud-container');
const myChart = echarts.init(chartDom);
// Prepare data for word cloud
const tagCloudData = [];
// ── Word cloud (local echarts) ──
const chartDom = document.getElementById('tag-cloud-container');
const myChart = echarts.init(chartDom);
const tagCloudData = [
{% for tag in tag_cloud %}
tagCloudData.push({
name: '{{ tag.name }}',
value: {{ tag.count }},
slug: '{{ tag.slug }}'
});
{ name: '{{ tag.name|escapejs }}', value: {{ tag.count }}, slug: '{{ tag.slug|escapejs }}' },
{% endfor %}
// Configure word cloud options
const option = {
series: [{
type: 'wordCloud',
shape: 'circle',
left: 'center',
top: 'center',
width: '100%',
height: '100%',
right: null,
bottom: null,
sizeRange: [14, 60], // Increased max size for better visibility
rotationRange: [0, 0], // No rotation for better readability like in the image
rotationStep: 0,
gridSize: 15, // Increased grid size for better spacing
drawOutOfBound: false,
layoutAnimation: true, // Enable animation when rendering
textStyle: {
fontFamily: 'sans-serif',
fontWeight: 'bold',
color: function () {
return tagColors[Math.floor(Math.random() * tagColors.length)];
}
},
emphasis: {
textStyle: {
fontWeight: 'bold',
shadowBlur: 10,
shadowColor: '#333'
}
},
data: tagCloudData
}]
};
// Set configuration and render chart
myChart.setOption(option);
// Add click event to navigate to tag detail page
myChart.on('click', function(params) {
const clickedTag = tagCloudData.find(tag => tag.name === params.name);
if (clickedTag) {
window.location.href = `/ui/tags/${clickedTag.slug}/`;
}
});
// Make chart responsive
window.addEventListener('resize', function() {
myChart.resize();
});
];
myChart.setOption({
tooltip: { show: true, formatter: p => p.name },
series: [{
type: 'wordCloud',
shape: 'circle',
left: 'center', top: 'center', width: '100%', height: '100%',
sizeRange: [16, 60], rotationRange: [0, 0], gridSize: 16,
drawOutOfBound: false, layoutAnimation: true,
textStyle: {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontWeight: 'bold',
color: () => tagColors[Math.floor(Math.random() * tagColors.length)],
},
emphasis: { textStyle: { fontWeight: 'bold', shadowBlur: 12, shadowColor: 'rgba(0,0,0,0.25)' } },
data: tagCloudData,
}],
});
myChart.on('click', p => {
const hit = tagCloudData.find(t => t.name === p.name);
if (hit) window.location.href = `/ui/tags/${hit.slug}/`;
});
window.addEventListener('resize', () => myChart.resize());
// Quick filter functionality
document.addEventListener('DOMContentLoaded', function() {
const filterInput = document.getElementById('filter-tags-input');
const tagItems = document.querySelectorAll('.tag-item');
const noResultsMessage = document.getElementById('no-results-message');
if (filterInput && tagItems.length > 0) {
filterInput.addEventListener('keyup', function() {
const filterTerm = this.value.toLowerCase().trim();
let visibleCount = 0;
tagItems.forEach(function(item) {
const tagName = item.getAttribute('data-tag-name');
if (tagName && tagName.includes(filterTerm)) {
item.style.display = '';
visibleCount++;
} else {
item.style.display = 'none';
}
});
// Show/hide no results message
if (visibleCount === 0 && filterTerm.length > 0) {
noResultsMessage.classList.remove('hidden');
} else {
noResultsMessage.classList.add('hidden');
}
// ── Live filter ──
const filterInput = document.getElementById('filter-tags-input');
const tagItems = document.querySelectorAll('.tag-item');
const noResults = document.getElementById('no-results-message');
if (filterInput) {
filterInput.addEventListener('input', () => {
const term = filterInput.value.toLowerCase().trim();
let visible = 0;
tagItems.forEach(item => {
const name = item.getAttribute('data-tag-name') || '';
const match = name.includes(term);
item.style.display = match ? '' : 'none';
if (match) visible++;
});
}
});
noResults.style.display = (visible === 0 && term.length > 0) ? 'block' : 'none';
});
}
});
</script>
{% endblock %}