Optimize frontend

This commit is contained in:
2026-03-29 11:10:37 +11:00
parent c26c620f78
commit 3a20fcb63a
53 changed files with 2936 additions and 840 deletions
+1 -1
View File
@@ -105,7 +105,7 @@ STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
CSRF_TRUSTED_ORIGINS = os.environ.get('CSRF_TRUSTED_ORIGINS', 'http://localhost:8000').split(',')
# Redis cache
REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
REDIS_URL = os.environ.get('REDIS_URL', 'redis://192.168.1.2:6379/0')
CACHES = {
'default': {
+15 -3
View File
@@ -27,14 +27,14 @@ dev:
#!/usr/bin/env bash
set -eu
source .venv/bin/activate
uv run manage.py tailwind start &
npm run dev &
TAILWIND_PID=$!
trap "kill $TAILWIND_PID 2>/dev/null" EXIT
uv run manage.py runserver 0.0.0.0:8000
# Run Tailwind CSS in watch mode (standalone)
tailwind:
source .venv/bin/activate && uv run manage.py tailwind start
npm run dev
# ── Database ──────────────────────────────────────────────────────────────────
@@ -60,9 +60,21 @@ superuser:
# ── Static Assets ─────────────────────────────────────────────────────────────
# Copy vendor JS/CSS from node_modules to static/vendor/
vendor:
node scripts/copy-vendor.js
# Build Tailwind CSS (minified, for production)
build-css:
source .venv/bin/activate && uv run manage.py tailwind build
npm run build:css
# Build the React search component with Vite
build-search:
npm run build:search
# Build all frontend assets: vendor copy + Tailwind + Vite (full production build)
build:
npm run build
# Collect all static files into staticfiles/
collectstatic:
+5
View File
@@ -11,6 +11,11 @@ class CollectionListView(ListView):
context_object_name = 'collections'
paginate_by = 12
def get_template_names(self):
if self.request.headers.get('HX-Request'):
return ['links/includes/collection_list_items.html']
return [self.template_name]
class CollectionDetailView(DetailView):
model = ImageCollection
template_name = 'links/collection_detail.html'
+5
View File
@@ -68,6 +68,11 @@ class PageListView(ListView):
context_object_name = 'pages'
paginate_by = 10
def get_template_names(self):
if self.request.headers.get('HX-Request'):
return ['links/includes/page_list_items.html']
return [self.template_name]
class PageDetailView(DetailView):
model = Page
template_name = 'links/page_detail.html'
+5
View File
@@ -20,6 +20,11 @@ class PostListView(ListView):
context_object_name = 'posts'
paginate_by = 10
def get_template_names(self):
if self.request.headers.get('HX-Request'):
return ['links/includes/post_list_items.html']
return [self.template_name]
def get_queryset(self):
queryset = Post.objects.all().order_by('-created_at')
+7 -5
View File
@@ -3,7 +3,8 @@
{% load static %}
{% block extra_css %}
<link rel="stylesheet" href="https://unpkg.com/dropzone@5/dist/min/dropzone.min.css">
<!-- Dropzone (CDN — page-specific, benefits from edge proximity) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/dropzone@6.0.0-beta.2/dist/dropzone.css">
<style>
.dropzone {
border: 2px dashed #e5e7eb;
@@ -187,7 +188,7 @@
<div class="flex flex-wrap gap-2 mt-2">
{% for tag in collection.tags.all %}
{% with number=forloop.counter %}
<a href="{% url 'tag-detail' tag.slug %}"
<a href="{% url 'tag-detail' tag.slug %}"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium transition duration-150 {% if number|divisibleby:10 %}tag-10{% elif number|divisibleby:9 %}tag-9{% elif number|divisibleby:8 %}tag-8{% elif number|divisibleby:7 %}tag-7{% elif number|divisibleby:6 %}tag-6{% elif number|divisibleby:5 %}tag-5{% elif number|divisibleby:4 %}tag-4{% elif number|divisibleby:3 %}tag-3{% elif number|divisibleby:2 %}tag-2{% else %}tag-1{% endif %}">
{{ tag.name }}
</a>
@@ -195,7 +196,7 @@
{% endfor %}
</div>
{% endif %}
</div>
<!-- Action Buttons -->
@@ -257,7 +258,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</a>
</a>
<button class="image-action" onclick="editDescription('{{ image.id }}', '{{ image.description|default:'' }}')" title="{% trans 'Edit Description' %}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
@@ -333,7 +334,8 @@
{% endblock %}
{% block extra_js %}
<script src="https://unpkg.com/dropzone@5/dist/min/dropzone.min.js"></script>
<!-- Dropzone (CDN — page-specific, benefits from edge proximity) -->
<script src="https://cdn.jsdelivr.net/npm/dropzone@6.0.0-beta.2/dist/dropzone.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const colors = ['blue', 'green', 'yellow', 'red', 'indigo', 'purple', 'pink'];
+2 -3
View File
@@ -2,7 +2,7 @@
{% load i18n %}
{% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
{# Select2 CSS now loaded globally from vendor in base.html #}
<style>
.select2-container--classic .select2-selection--multiple {
border: 1px solid #d1d5db !important;
@@ -118,8 +118,7 @@
{% endblock %}
{% block extra_js %}
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
{# jQuery and Select2 are loaded globally from vendor in base.html #}
<script>
$(document).ready(function() {
// Initialize Select2 for tags
+2 -87
View File
@@ -15,95 +15,10 @@
</a>
</div>
<!-- Collections Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6 md:gap-8">
{% for collection in collections %}
<div class="group relative w-full">
<!-- Fixed size container -->
<div class="bg-white rounded-lg shadow-sm overflow-hidden hover:shadow-md transition-all duration-200 h-[280px] sm:h-[320px]">
<a href="{% url 'collection-detail' collection.pk %}" class="block h-full">
<div class="flex flex-col h-full">
<!-- Image Preview Container - Fixed Height -->
<div class="h-[160px] sm:h-[200px] bg-gradient-to-br from-gray-50 to-gray-100 p-2 sm:p-3">
<div class="grid grid-cols-2 gap-1.5 sm:gap-2 h-full">
{% with images=collection.images.all|slice:":4" %}
{% for image in images %}
<div class="aspect-w-1 aspect-h-1 overflow-hidden rounded-lg bg-gray-200 shadow-sm
{% if forloop.counter > 2 %}hidden sm:block{% endif %}">
<img src="{{ image.get_thumbnail_url }}"
alt="{{ image.title }}"
class="object-cover w-full h-full">
</div>
{% empty %}
<div class="col-span-2 flex flex-col items-center justify-center h-full bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border-2 border-dashed border-gray-200">
<svg class="w-8 h-8 sm:w-12 sm:h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<p class="mt-1 sm:mt-2 text-xs sm:text-sm text-gray-500">{% trans "No images yet" %}</p>
</div>
{% endfor %}
{% endwith %}
</div>
</div>
<!-- Collection Info - Flex Grow to Fill Remaining Space -->
<div class="flex-1 p-3 sm:p-4 flex flex-col">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900 mb-2">{{ collection.name }}</h3>
{% if collection.description %}
<p class="text-sm text-gray-600 mb-4">{{ collection.description }}</p>
{% endif %}
<!-- Image Count -->
<p class="mt-0.5 text-xs sm:text-sm text-gray-500">
{{ collection.images.count }} {% trans "images" %}
</p>
</div>
</div>
</div>
</a>
<!-- Action Buttons -->
<div class="absolute top-1.5 sm:top-2 right-1.5 sm:right-2 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<a href="{% url 'collection-update' collection.pk %}"
class="p-1 sm:p-1.5 bg-white text-gray-600 hover:text-blue-600 rounded-full hover:bg-blue-50 shadow-sm transition-colors duration-200"
title="{% trans 'Edit Collection' %}">
<svg class="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</a>
<button onclick="deleteCollection('{{ collection.pk }}'); event.preventDefault();"
class="p-1 sm:p-1.5 bg-white text-gray-600 hover:text-red-600 rounded-full hover:bg-red-50 shadow-sm transition-colors duration-200"
title="{% trans 'Delete Collection' %}">
<svg class="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
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>
</button>
</div>
</div>
</div>
{% empty %}
<div class="col-span-full flex flex-col items-center justify-center py-12 bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border-2 border-dashed border-gray-300">
<svg class="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<h3 class="mt-4 text-lg font-medium text-gray-900">{% trans "No collections" %}</h3>
<p class="mt-2 text-base text-gray-500">{% trans "Get started by creating a new collection." %}</p>
<a href="{% url 'collection-create' %}"
class="mt-6 inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700">
{% trans "Create Collection" %}
</a>
</div>
{% endfor %}
<div id="paginated-content">
{% include "links/includes/collection_list_items.html" %}
</div>
{% include "links/includes/pagination.html" %}
</div>
{% block extra_js %}
<script>
// Add getCookie function
-1
View File
@@ -3,7 +3,6 @@
{% load static %}
{% block extra_css %}
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
{% endblock %}
{% block content %}
@@ -0,0 +1,89 @@
{% load i18n %}
<!-- Collections Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6 md:gap-8">
{% for collection in collections %}
<div class="group relative w-full">
<!-- Fixed size container -->
<div class="bg-white rounded-lg shadow-sm overflow-hidden hover:shadow-md transition-all duration-200 h-[280px] sm:h-[320px]">
<a href="{% url 'collection-detail' collection.pk %}" class="block h-full">
<div class="flex flex-col h-full">
<!-- Image Preview Container - Fixed Height -->
<div class="h-[160px] sm:h-[200px] bg-gradient-to-br from-gray-50 to-gray-100 p-2 sm:p-3">
<div class="grid grid-cols-2 gap-1.5 sm:gap-2 h-full">
{% with images=collection.images.all|slice:":4" %}
{% for image in images %}
<div class="aspect-w-1 aspect-h-1 overflow-hidden rounded-lg bg-gray-200 shadow-sm
{% if forloop.counter > 2 %}hidden sm:block{% endif %}">
<img src="{{ image.get_thumbnail_url }}"
alt="{{ image.title }}"
class="object-cover w-full h-full">
</div>
{% empty %}
<div class="col-span-2 flex flex-col items-center justify-center h-full bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border-2 border-dashed border-gray-200">
<svg class="w-8 h-8 sm:w-12 sm:h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<p class="mt-1 sm:mt-2 text-xs sm:text-sm text-gray-500">{% trans "No images yet" %}</p>
</div>
{% endfor %}
{% endwith %}
</div>
</div>
<!-- Collection Info - Flex Grow to Fill Remaining Space -->
<div class="flex-1 p-3 sm:p-4 flex flex-col">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900 mb-2">{{ collection.name }}</h3>
{% if collection.description %}
<p class="text-sm text-gray-600 mb-4">{{ collection.description }}</p>
{% endif %}
<!-- Image Count -->
<p class="mt-0.5 text-xs sm:text-sm text-gray-500">
{{ collection.images.count }} {% trans "images" %}
</p>
</div>
</div>
</div>
</a>
<!-- Action Buttons -->
<div class="absolute top-1.5 sm:top-2 right-1.5 sm:right-2 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<a href="{% url 'collection-update' collection.pk %}"
class="p-1 sm:p-1.5 bg-white text-gray-600 hover:text-blue-600 rounded-full hover:bg-blue-50 shadow-sm transition-colors duration-200"
title="{% trans 'Edit Collection' %}">
<svg class="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</a>
<button onclick="deleteCollection('{{ collection.pk }}'); event.preventDefault();"
class="p-1 sm:p-1.5 bg-white text-gray-600 hover:text-red-600 rounded-full hover:bg-red-50 shadow-sm transition-colors duration-200"
title="{% trans 'Delete Collection' %}">
<svg class="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
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>
</button>
</div>
</div>
</div>
{% empty %}
<div class="col-span-full flex flex-col items-center justify-center py-12 bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border-2 border-dashed border-gray-300">
<svg class="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<h3 class="mt-4 text-lg font-medium text-gray-900">{% trans "No collections" %}</h3>
<p class="mt-2 text-base text-gray-500">{% trans "Get started by creating a new collection." %}</p>
<a href="{% url 'collection-create' %}"
class="mt-6 inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700">
{% trans "Create Collection" %}
</a>
</div>
{% endfor %}
</div>
{% include "links/includes/pagination.html" %}
@@ -0,0 +1,133 @@
{% load i18n %}
{% load static %}
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<ul class="divide-y divide-gray-200">
{% for page in pages %}
<li class="p-4">
<div class="flex space-x-4">
<!-- Thumbnail Container -->
<div class="hidden sm:block flex-shrink-0 w-48 h-48 rounded-lg overflow-hidden bg-gray-100 border border-gray-200">
<div class="w-full h-full flex items-center justify-center">
{% with latest_screenshot=page.screenshots.first %}
{% if latest_screenshot and latest_screenshot.path %}
<img src="{{ latest_screenshot.get_url }}"
alt="Page thumbnail"
class="w-full h-full object-cover"
loading="lazy"
onerror="this.onerror=null; this.src='{% static 'images/default-screenshot.png' %}'; this.classList.add('object-contain', 'p-4');">
{% else %}
<img src="{% static 'images/default-screenshot.png' %}"
alt="Default thumbnail"
class="w-3/4 h-3/4 object-contain">
{% endif %}
{% endwith %}
</div>
</div>
<!-- Content Container -->
<div class="flex-1 min-w-0">
<div class="flex justify-between items-start">
<div class="flex-1 min-w-0 pr-4">
<a href="{{ page.get_absolute_url }}" class="text-lg font-medium text-blue-600 hover:text-blue-800">
{{ page.title|default:"Untitled" }}
</a>
<a href="{{ page.url }}" target="_blank" rel="noopener noreferrer"
class="block mt-1 text-sm text-gray-600 hover:text-gray-900 break-all">
{{ page.url }}
</a>
</div>
<div class="flex flex-col items-end space-y-2">
<!-- Status Badge -->
{% if page.process_status == 'completed' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
{% trans "Completed" %}
</span>
{% elif page.process_status == 'processing' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
{% trans "Processing" %}
</span>
{% elif page.process_status == 'failed' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
{% trans "Failed" %}
{% if page.retry_count > 0 %}
({{ page.retry_count }}/3)
{% endif %}
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{% trans "Pending" %}
</span>
{% endif %}
<!-- Action Buttons -->
<div class="flex space-x-2">
<a href="{% url 'page-update' page.pk %}"
class="p-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</a>
<a href="{% url 'page-delete' page.pk %}"
class="p-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
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>
</a>
</div>
</div>
</div>
<!-- Summary -->
<div class="mt-2 flex-1">
<p class="text-gray-600 line-clamp-3">{{ page.summary }}</p>
</div>
<!-- Footer -->
<div class="mt-2 text-sm text-gray-500">
{% trans "Updated" %}: {{ page.updated_at|date:"Y-m-d H:i" }}
</div>
</div>
</div>
</li>
{% empty %}
<li class="px-4 py-2 text-center text-gray-500">
{% trans "No pages found." %}
</li>
{% endfor %}
</ul>
</div>
{% if is_paginated %}
<div class="mt-4 flex justify-center">
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}"
hx-get="?page={{ page_obj.previous_page_number }}"
hx-target="#paginated-content"
hx-swap="innerHTML"
hx-push-url="true"
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
{% trans "Previous" %}
</a>
{% endif %}
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
{{ page_obj.number }} / {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}"
hx-get="?page={{ page_obj.next_page_number }}"
hx-target="#paginated-content"
hx-swap="innerHTML"
hx-push-url="true"
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
{% trans "Next" %}
</a>
{% endif %}
</nav>
</div>
{% endif %}
@@ -5,6 +5,10 @@
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}"
hx-get="?page={{ page_obj.previous_page_number }}"
hx-target="#paginated-content"
hx-swap="innerHTML"
hx-push-url="true"
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
{% trans "Previous" %}
</a>
@@ -16,6 +20,10 @@
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}"
hx-get="?page={{ page_obj.next_page_number }}"
hx-target="#paginated-content"
hx-swap="innerHTML"
hx-push-url="true"
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
{% trans "Next" %}
</a>
@@ -0,0 +1,80 @@
{% load i18n %}
<!-- Post List -->
<div class="bg-white shadow rounded-lg overflow-hidden">
<ul class="divide-y divide-gray-200">
{% for post in posts %}
<li class="hover:bg-gray-50 transition duration-150">
<div class="p-4">
<div class="space-y-3">
<!-- Post Title and Summary -->
<div>
<h2 class="text-lg font-medium text-gray-900">
<a href="{% url 'post-detail' post.id %}" class="hover:text-blue-600 hover:underline">
{{ post.title }}
</a>
</h2>
{% if post.summary %}
<p class="mt-1 text-sm text-gray-600 line-clamp-2">
{{ post.summary }}
</p>
{% endif %}
</div>
<!-- Tags -->
{% if post.tags.exists %}
<div class="flex flex-wrap gap-2 mt-2">
{% for tag in post.tags.all %}
{% with number=forloop.counter %}
<a href="{% url 'tag-detail' tag.slug %}"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium transition duration-150 {% if number|divisibleby:10 %}tag-10{% elif number|divisibleby:9 %}tag-9{% elif number|divisibleby:8 %}tag-8{% elif number|divisibleby:7 %}tag-7{% elif number|divisibleby:6 %}tag-6{% elif number|divisibleby:5 %}tag-5{% elif number|divisibleby:4 %}tag-4{% elif number|divisibleby:3 %}tag-3{% elif number|divisibleby:2 %}tag-2{% else %}tag-1{% endif %}">
{{ tag.name }}
</a>
{% endwith %}
{% endfor %}
</div>
{% endif %}
<!-- Timestamps and Actions -->
<div class="mt-4 flex flex-col sm:flex-row sm:items-center sm:justify-between border-t border-gray-100 pt-3">
<div class="flex items-center gap-4 text-xs text-gray-500">
<div class="flex items-center">
<svg class="w-3.5 h-3.5 mr-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
{% trans "Created" %}: {{ post.created_at|date:"Y-m-d H:i" }}
</div>
<div class="flex items-center">
<svg class="w-3.5 h-3.5 mr-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
{% trans "Updated" %}: {{ post.updated_at|date:"Y-m-d H:i" }}
</div>
</div>
<div class="flex items-center gap-2 mt-3 sm:mt-0">
<a href="{% url 'post-update' post.pk %}" class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium text-green-700 bg-green-50 rounded-md hover:bg-green-100 transition duration-150">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
{% trans "Edit" %}
</a>
<a href="{% url 'post-delete' post.pk %}" class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium text-red-700 bg-red-50 rounded-md hover:bg-red-100 transition duration-150">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
</div>
</li>
{% empty %}
<li class="px-4 py-8 text-center text-gray-500">
{% trans "No post found." %}
</li>
{% endfor %}
</ul>
</div>
{% include "links/includes/pagination.html" %}
-2
View File
@@ -4,7 +4,6 @@
{% block extra_css %}
<link href="{% static 'css/dist/styles.css' %}" rel="stylesheet">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>[x-cloak] { display: none !important; }</style>
{% endblock %}
@@ -561,4 +560,3 @@ function jobsManager() {
}
</script>
{% endblock %}
+2 -1
View File
@@ -223,7 +223,8 @@
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Chart.js (CDN — page-specific, benefits from edge proximity) -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var ctx = document.getElementById('clickChart').getContext('2d');
+1 -1
View File
@@ -144,7 +144,7 @@
{% block extra_js %}
<script src="{% static 'simplemde/simplemde.min.js' %}"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
{# jQuery and Select2 are loaded globally from vendor in base.html #}
<script>
$(document).ready(function() {
// Initialize Select2 for tags
@@ -725,8 +725,8 @@
</div>
</div>
<!-- Chart.js Library -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<!-- Chart.js (CDN — page-specific, benefits from edge proximity) -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script>
<script>
console.log('🔥 FIRE Planning JavaScript loaded!');
@@ -4,7 +4,7 @@
{% block title %}{% trans "Image Gallery" %} - {% trans "GoLinks" %}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="{% static 'vendor/fontawesome/css/all.min.css' %}">
<style>
* {
margin: 0;
+1 -2
View File
@@ -4,7 +4,6 @@
{% load static %}
{% block extra_head %}
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
[x-cloak] { display: none !important; }
</style>
@@ -99,7 +98,7 @@
<!-- Tags -->
<div class="mb-6">
<div class="flex items-center justify-between mb-2">
<h2 class="text-sm font-medium text-gray-500">{% trans "Tags" %}</h2>
<h2 class="text-sm font-medium text-gray-500">{% trans "Tags" %}</h2>
</div>
<div class="flex flex-wrap gap-2">
{% for tag in page.tags.all %}
+4 -8
View File
@@ -3,10 +3,8 @@
{% load static %}
{% block extra_css %}
<!-- SimpleMDE CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
<!-- Select2 CSS -->
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link rel="stylesheet" href="{% static 'simplemde/simplemde.min.css' %}">
{# Select2 CSS is loaded globally from vendor in base.html #}
<style>
.overlay {
display: none;
@@ -197,10 +195,8 @@
{% endblock %}
{% block extra_js %}
<!-- SimpleMDE JavaScript -->
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
<!-- Select2 JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script src="{% static 'simplemde/simplemde.min.js' %}"></script>
{# jQuery and Select2 are loaded globally from vendor in base.html #}
<script>
// Clear SimpleMDE autosave cache to prevent old cached content from overriding DB updates
// This is especially important after task list checkbox toggles
+2 -119
View File
@@ -11,125 +11,8 @@
</a>
</div>
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<ul class="divide-y divide-gray-200">
{% for page in pages %}
<li class="p-4">
<div class="flex space-x-4">
<!-- Thumbnail Container -->
<div class="hidden sm:block flex-shrink-0 w-48 h-48 rounded-lg overflow-hidden bg-gray-100 border border-gray-200">
<div class="w-full h-full flex items-center justify-center">
{% with latest_screenshot=page.screenshots.first %}
{% if latest_screenshot and latest_screenshot.path %}
<img src="{{ latest_screenshot.get_url }}"
alt="Page thumbnail"
class="w-full h-full object-cover"
loading="lazy"
onerror="this.onerror=null; this.src='{% static 'images/default-screenshot.png' %}'; this.classList.add('object-contain', 'p-4');">
{% else %}
<img src="{% static 'images/default-screenshot.png' %}"
alt="Default thumbnail"
class="w-3/4 h-3/4 object-contain">
{% endif %}
{% endwith %}
</div>
</div>
<!-- Content Container -->
<div class="flex-1 min-w-0">
<div class="flex justify-between items-start">
<div class="flex-1 min-w-0 pr-4">
<a href="{{ page.get_absolute_url }}" class="text-lg font-medium text-blue-600 hover:text-blue-800">
{{ page.title|default:"Untitled" }}
</a>
<a href="{{ page.url }}" target="_blank" rel="noopener noreferrer"
class="block mt-1 text-sm text-gray-600 hover:text-gray-900 break-all">
{{ page.url }}
</a>
</div>
<div class="flex flex-col items-end space-y-2">
<!-- Status Badge -->
{% if page.process_status == 'completed' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
{% trans "Completed" %}
</span>
{% elif page.process_status == 'processing' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
{% trans "Processing" %}
</span>
{% elif page.process_status == 'failed' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
{% trans "Failed" %}
{% if page.retry_count > 0 %}
({{ page.retry_count }}/3)
{% endif %}
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{% trans "Pending" %}
</span>
{% endif %}
<!-- Action Buttons -->
<div class="flex space-x-2">
<a href="{% url 'page-update' page.pk %}"
class="p-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</a>
<a href="{% url 'page-delete' page.pk %}"
class="p-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
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>
</a>
</div>
</div>
</div>
<!-- Summary -->
<div class="mt-2 flex-1">
<p class="text-gray-600 line-clamp-3">{{ page.summary }}</p>
</div>
<!-- Footer -->
<div class="mt-2 text-sm text-gray-500">
{% trans "Updated" %}: {{ page.updated_at|date:"Y-m-d H:i" }}
</div>
</div>
</div>
</li>
{% empty %}
<li class="px-4 py-2 text-center text-gray-500">
{% trans "No pages found." %}
</li>
{% endfor %}
</ul>
<div id="paginated-content">
{% include "links/includes/page_list_items.html" %}
</div>
{% if is_paginated %}
<div class="mt-4 flex justify-center">
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}" class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
{% trans "Previous" %}
</a>
{% endif %}
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
{{ page_obj.number }} / {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}" class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
{% trans "Next" %}
</a>
{% endif %}
</nav>
</div>
{% endif %}
</div>
{% endblock %}
+4 -10
View File
@@ -3,10 +3,8 @@
{% load static %}
{% block extra_css %}
<!-- SimpleMDE CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
<!-- Select2 CSS -->
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link rel="stylesheet" href="{% static 'simplemde/simplemde.min.css' %}">
{# Select2 CSS now loaded globally from vendor in base.html #}
<style>
.CodeMirror {
height: 400px;
@@ -129,12 +127,8 @@
{% endblock %}
{% block extra_js %}
<!-- SimpleMDE JavaScript -->
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
<!-- jQuery (required for Select2) -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- Select2 JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script src="{% static 'simplemde/simplemde.min.js' %}"></script>
{# jQuery and Select2 are now loaded globally from vendor in base.html #}
<script>
// Clear SimpleMDE autosave cache to prevent old cached content from overriding DB updates
// This is especially important after task list checkbox toggles
+2 -77
View File
@@ -42,83 +42,8 @@
</form>
</div>
<!-- Post List -->
<div class="bg-white shadow rounded-lg overflow-hidden">
<ul class="divide-y divide-gray-200">
{% for post in posts %}
<li class="hover:bg-gray-50 transition duration-150">
<div class="p-4">
<div class="space-y-3">
<!-- Post Title and Summary -->
<div>
<h2 class="text-lg font-medium text-gray-900">
<a href="{% url 'post-detail' post.id %}" class="hover:text-blue-600 hover:underline">
{{ post.title }}
</a>
</h2>
{% if post.summary %}
<p class="mt-1 text-sm text-gray-600 line-clamp-2">
{{ post.summary }}
</p>
{% endif %}
</div>
<!-- Tags -->
{% if post.tags.exists %}
<div class="flex flex-wrap gap-2 mt-2">
{% for tag in post.tags.all %}
{% with number=forloop.counter %}
<a href="{% url 'tag-detail' tag.slug %}"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium transition duration-150 {% if number|divisibleby:10 %}tag-10{% elif number|divisibleby:9 %}tag-9{% elif number|divisibleby:8 %}tag-8{% elif number|divisibleby:7 %}tag-7{% elif number|divisibleby:6 %}tag-6{% elif number|divisibleby:5 %}tag-5{% elif number|divisibleby:4 %}tag-4{% elif number|divisibleby:3 %}tag-3{% elif number|divisibleby:2 %}tag-2{% else %}tag-1{% endif %}">
{{ tag.name }}
</a>
{% endwith %}
{% endfor %}
</div>
{% endif %}
<!-- Timestamps and Actions -->
<div class="mt-4 flex flex-col sm:flex-row sm:items-center sm:justify-between border-t border-gray-100 pt-3">
<div class="flex items-center gap-4 text-xs text-gray-500">
<div class="flex items-center">
<svg class="w-3.5 h-3.5 mr-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
{% trans "Created" %}: {{ post.created_at|date:"Y-m-d H:i" }}
</div>
<div class="flex items-center">
<svg class="w-3.5 h-3.5 mr-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
{% trans "Updated" %}: {{ post.updated_at|date:"Y-m-d H:i" }}
</div>
</div>
<div class="flex items-center gap-2 mt-3 sm:mt-0">
<a href="{% url 'post-update' post.pk %}" class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium text-green-700 bg-green-50 rounded-md hover:bg-green-100 transition duration-150">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
{% trans "Edit" %}
</a>
<a href="{% url 'post-delete' post.pk %}" class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium text-red-700 bg-red-50 rounded-md hover:bg-red-100 transition duration-150">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
</div>
</li>
{% empty %}
<li class="px-4 py-8 text-center text-gray-500">
{% trans "No post found." %}
</li>
{% endfor %}
</ul>
<div id="paginated-content">
{% include "links/includes/post_list_items.html" %}
</div>
{% include "links/includes/pagination.html" %}
</div>
{% endblock %}
+1 -1
View File
@@ -6,7 +6,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ post.title }}</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" rel="stylesheet">
<link href="{% static 'css/dist/styles.css' %}" rel="stylesheet">
<link href="{% static 'css/markdown.css' %}" rel="stylesheet">
<style>
/* Think Panel Styles */
+4 -474
View File
@@ -5,11 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Advanced Search - GoLinks</title>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="{% static 'css/dist/styles.css' %}" rel="stylesheet">
<link href="{% static 'vendor/fontawesome/css/all.min.css' %}" rel="stylesheet">
<style>
@keyframes shimmer {
0% { background-position: -1000px 0; }
@@ -28,474 +25,7 @@
</head>
<body class="bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 min-h-screen">
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useCallback, useMemo } = React;
// Badge Component
const Badge = ({ children, variant = "default", className = "" }) => {
const variants = {
default: "bg-gray-100 text-gray-800",
link: "bg-blue-100 text-blue-800",
page: "bg-green-100 text-green-800",
post: "bg-purple-100 text-purple-800"
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${variants[variant]} ${className}`}>
{children}
</span>
);
};
// Card Component
const Card = ({ children, className = "", hover = false }) => (
<div className={`bg-white rounded-lg shadow-sm border border-gray-200 ${hover ? 'hover:shadow-md transition-shadow duration-200' : ''} ${className}`}>
{children}
</div>
);
// Input Component
const Input = ({ className = "", ...props }) => (
<input
className={`flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
{...props}
/>
);
// Select Component
const Select = ({ children, className = "", ...props }) => (
<select
className={`flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
{...props}
>
{children}
</select>
);
// Button Component
const Button = ({ children, variant = "default", className = "", ...props }) => {
const variants = {
default: "bg-blue-600 text-white hover:bg-blue-700",
outline: "border-2 border-gray-300 bg-white hover:bg-gray-50",
ghost: "hover:bg-gray-100"
};
return (
<button
className={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium ring-offset-white transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${variants[variant]} ${className}`}
{...props}
>
{children}
</button>
);
};
// Skeleton Loader
const Skeleton = ({ className = "" }) => (
<div className={`shimmer rounded ${className}`}></div>
);
// Search Result Item Component
const SearchResultItem = ({ result }) => {
const typeIcons = {
link: "fa-link",
page: "fa-file-alt",
post: "fa-newspaper"
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
return (
<Card hover className="p-4">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 mt-1">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
result.type === 'link' ? 'bg-blue-100 text-blue-600' :
result.type === 'page' ? 'bg-green-100 text-green-600' :
'bg-purple-100 text-purple-600'
}`}>
<i className={`fas ${typeIcons[result.type]}`}></i>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<a
href={result.type === 'link' ? result.url : result.detail_url || result.url}
className="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors truncate"
target={result.type === 'link' ? '_blank' : '_self'}
rel={result.type === 'link' ? 'noopener noreferrer' : ''}
>
{result.title}
</a>
<Badge variant={result.type}>
{result.type}
</Badge>
</div>
{result.type === 'link' && result.original_url && (
<a
href={result.original_url}
className="text-sm text-gray-600 hover:text-gray-900 truncate block mb-2"
target="_blank"
rel="noopener noreferrer"
>
<i className="fas fa-external-link-alt mr-1"></i>
{result.original_url}
</a>
)}
{(result.description || result.summary) && (
<p className="text-sm text-gray-600 line-clamp-2 mb-2">
{result.description || result.summary}
</p>
)}
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>
<i className="fas fa-calendar mr-1"></i>
{formatDate(result.created_at)}
</span>
{result.click_count !== undefined && (
<span>
<i className="fas fa-mouse-pointer mr-1"></i>
{result.click_count} clicks
</span>
)}
</div>
{result.tags && result.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{result.tags.map(tag => (
<Badge key={tag.id} variant="default" className="text-xs">
<i className="fas fa-tag mr-1"></i>
{tag.name}
</Badge>
))}
</div>
)}
</div>
<div className="flex-shrink-0">
<a
href={result.type === 'link' ? `/link/${result.id}/edit/` : result.type === 'page' ? `/ui/pages/${result.id}/edit/` : `/ui/posts/${result.id}/edit/`}
className="text-gray-400 hover:text-blue-600 transition-colors"
title="Edit"
>
<i className="fas fa-edit"></i>
</a>
</div>
</div>
</Card>
);
};
// Main Search App Component
const SearchApp = () => {
const [query, setQuery] = useState('');
const [type, setType] = useState('');
const [sort, setSort] = useState('relevance');
const [isVector, setIsVector] = useState(false);
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [error, setError] = useState(null);
const perPage = 20;
const performSearch = useCallback(async (searchQuery, searchType, searchSort, searchPage) => {
if (!searchQuery.trim()) {
setResults([]);
setTotal(0);
return;
}
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
q: searchQuery,
type: searchType,
sort: searchSort,
page: searchPage,
per_page: perPage
});
const endpoint = isVector ? "/search/api/vector/" : "/search/api/v2/";
const response = await fetch(`${endpoint}?${params}`);
const data = await response.json();
if (response.ok) {
setResults(data.results);
setTotal(data.total);
} else {
setError(data.error || 'Search failed');
setResults([]);
}
} catch (err) {
setError('Network error. Please try again.');
setResults([]);
} finally {
setLoading(false);
}
}, []);
const handleSearch = useCallback((e) => {
e.preventDefault();
setPage(1);
performSearch(query, type, sort, 1);
}, [query, type, sort, performSearch]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const urlQuery = params.get('q') || '';
const urlType = params.get('type') || '';
const urlSort = params.get('sort') || 'relevance';
setQuery(urlQuery);
setType(urlType);
setSort(urlSort);
if (urlQuery) {
performSearch(urlQuery, urlType, urlSort, 1);
}
}, [performSearch]);
useEffect(() => {
if (query) {
const params = new URLSearchParams({
q: query,
...(type && { type }),
...(sort !== 'relevance' && { sort })
});
window.history.replaceState({}, '', `?${params}`);
}
}, [query, type, sort]);
const handlePageChange = (newPage) => {
setPage(newPage);
performSearch(query, type, sort, newPage);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const totalPages = Math.ceil(total / perPage);
return (
<div className="min-h-screen py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="mb-8 text-center">
<a href="/" className="inline-block hover:opacity-80 transition-opacity">
<h1 className="text-4xl font-bold text-gray-900 mb-2 flex items-center justify-center gap-3">
<i className="fas fa-search text-blue-600"></i>
Advanced Search
</h1>
</a>
<p className="text-gray-600">Search through all Links, Pages, and Posts</p>
</div>
{/* Search Form */}
<Card className="p-6 mb-8 glass">
<form onSubmit={handleSearch} className="space-y-4">
{/* Search Input */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Search Query
</label>
<div className="relative">
<i className="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
<Input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Enter keywords to search..."
className="pl-10"
/>
</div>
</div>
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Filter by Type
</label>
<Select value={type} onChange={(e) => setType(e.target.value)}>
<option value="">All Types</option>
<option value="link">Links</option>
<option value="page">Pages</option>
<option value="post">Posts</option>
</Select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sort By
</label>
<div className="flex items-center gap-2 mb-2"><label className="text-sm font-medium text-gray-700">AI Vector Search</label><input type="checkbox" checked={isVector} onChange={(e) => setIsVector(e.target.checked)} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" /></div>
<Select value={sort} onChange={(e) => setSort(e.target.value)} disabled={isVector}>
<option value="relevance">Most Relevant</option>
<option value="newest">Newest First</option>
<option value="oldest">Oldest First</option>
</Select>
</div>
</div>
{/* Search Button */}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<>
<i className="fas fa-spinner fa-spin mr-2"></i>
Searching...
</>
) : (
<>
<i className="fas fa-search mr-2"></i>
Search
</>
)}
</Button>
</form>
</Card>
{/* Results */}
{query && (
<div>
{/* Results Header */}
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
{loading ? (
'Searching...'
) : error ? (
<span className="text-red-600">
<i className="fas fa-exclamation-circle mr-2"></i>
{error}
</span>
) : (
<>
Search results for: <span className="text-blue-600">"{query}"</span>
{total > 0 && (
<span className="text-gray-500 text-sm ml-2">
({total} {total === 1 ? 'result' : 'results'})
</span>
)}
</>
)}
</h2>
</div>
{/* Loading Skeletons */}
{loading && (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<Card key={i} className="p-4">
<div className="flex items-start gap-4">
<Skeleton className="w-10 h-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
</Card>
))}
</div>
)}
{/* Results List */}
{!loading && !error && results.length > 0 && (
<div className="space-y-4">
{results.map(result => (
<SearchResultItem key={`${result.type}-${result.id}`} result={result} />
))}
</div>
)}
{/* No Results */}
{!loading && !error && results.length === 0 && query && (
<Card className="p-12 text-center">
<i className="fas fa-search text-6xl text-gray-300 mb-4"></i>
<h3 className="text-lg font-medium text-gray-900 mb-2">No results found</h3>
<p className="text-gray-500">Try adjusting your search terms or filters</p>
</Card>
)}
{/* Pagination */}
{!loading && totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<Button
variant="outline"
onClick={() => handlePageChange(page - 1)}
disabled={page === 1}
>
<i className="fas fa-chevron-left mr-2"></i>
Previous
</Button>
<div className="flex items-center gap-2">
{[...Array(Math.min(5, totalPages))].map((_, i) => {
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<Button
key={pageNum}
variant={page === pageNum ? "default" : "outline"}
onClick={() => handlePageChange(pageNum)}
className="w-10 h-10 p-0"
>
{pageNum}
</Button>
);
})}
</div>
<Button
variant="outline"
onClick={() => handlePageChange(page + 1)}
disabled={page === totalPages}
>
Next
<i className="fas fa-chevron-right ml-2"></i>
</Button>
</div>
)}
</div>
)}
{/* Empty State */}
{!query && (
<Card className="p-12 text-center glass">
<i className="fas fa-search text-6xl text-blue-200 mb-4"></i>
<h3 className="text-xl font-medium text-gray-900 mb-2">Start Searching</h3>
<p className="text-gray-600 max-w-md mx-auto">
Enter a search query to find links, pages, and posts.
You can search by title, content, URL, or tags.
</p>
</Card>
)}
</div>
</div>
);
};
// Render the app
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<SearchApp />);
</script>
<script src="{% static 'dist/search.js' %}"></script>
</body>
</html>
const { useState, useEffect, useCallback, useMemo } = React;
+10 -10
View File
@@ -33,10 +33,10 @@
{% 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...' %}"
<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">
@@ -101,15 +101,15 @@
</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>
<!-- Include ECharts and wordcloud extension -->
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<!-- 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>
@@ -209,21 +209,21 @@
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++;
@@ -231,7 +231,7 @@
item.style.display = 'none';
}
});
// Show/hide no results message
if (visibleCount === 0 && filterTerm.length > 0) {
noResultsMessage.classList.remove('hidden');
+1846 -24
View File
File diff suppressed because it is too large Load Diff
+22 -4
View File
@@ -2,12 +2,30 @@
"name": "url-manager",
"version": "1.0.0",
"scripts": {
"dev": "tailwindcss -i ./new_theme/static/css/src/styles.css -o ./new_theme/static/css/dist/styles.css --watch",
"build": "tailwindcss -i ./new_theme/static/css/src/styles.css -o ./new_theme/static/css/dist/styles.css --minify"
"dev": "tailwindcss -i ./new_theme/static_src/src/styles.css -o ./new_theme/static/css/dist/styles.css --watch",
"build:css": "tailwindcss -i ./new_theme/static_src/src/styles.css -o ./new_theme/static/css/dist/styles.css --minify",
"build:search": "vite build",
"build:vendor": "node scripts/copy-vendor.js",
"build": "npm run build:vendor && npm run build:css && npm run build:search"
},
"dependencies": {
"tailwindcss": "^3.3.0",
"@fortawesome/fontawesome-free": "^6.7.2",
"@tailwindcss/typography": "^0.5.9",
"cross-spawn": ">=7.0.5"
"alpinejs": "^3.14.9",
"chart.js": "^4.5.1",
"cross-spawn": ">=7.0.5",
"dropzone": "^6.0.0-beta.2",
"echarts": "^5.6.0",
"echarts-wordcloud": "^2.1.0",
"htmx.org": "^2.0.4",
"jquery": "^3.7.1",
"select2": "^4.1.0-rc.0",
"tailwindcss": "^3.3.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"vite": "^6.2.0"
}
}
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
/**
* Copies required vendor dist files from node_modules → static/vendor/
* Run with: node scripts/copy-vendor.js
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const VENDOR = path.join(ROOT, 'static', 'vendor');
function copy(src, dest) {
const destDir = path.dirname(dest);
fs.mkdirSync(destDir, { recursive: true });
fs.copyFileSync(src, dest);
console.log(`${path.relative(ROOT, dest)}`);
}
function nm(pkg) {
return path.join(ROOT, 'node_modules', pkg);
}
function out(rel) {
return path.join(VENDOR, rel);
}
console.log('Copying vendor assets to static/vendor/ ...');
// ---------- Alpine.js ----------
// Served via CDN
// ---------- Font Awesome 6 ----------
// Served via CDN
// ---------- jQuery ----------
// Served via CDN
// ---------- Select2 ----------
// Served via CDN
// ---------- Dropzone ----------
// Served via CDN (page-specific; removed from self-hosting)
// ---------- htmx ----------
copy(nm('htmx.org/dist/htmx.min.js'), out('htmx/htmx.min.js'));
// ---------- ECharts ----------
// Served via CDN (page-specific; removed from self-hosting)
// ---------- Chart.js ----------
// Served via CDN (page-specific; removed from self-hosting)
console.log('Done.\n');
+40
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
@keyframes passing-through{0%{opacity:0;transform:translateY(40px)}30%,70%{opacity:1;transform:translateY(0px)}100%{opacity:0;transform:translateY(-40px)}}@keyframes slide-in{0%{opacity:0;transform:translateY(40px)}30%{opacity:1;transform:translateY(0px)}}@keyframes pulse{0%{transform:scale(1)}10%{transform:scale(1.1)}20%{transform:scale(1)}}.dropzone,.dropzone *{box-sizing:border-box}.dropzone{min-height:150px;border:1px solid rgba(0,0,0,.8);border-radius:5px;padding:20px 20px}.dropzone.dz-clickable{cursor:pointer}.dropzone.dz-clickable *{cursor:default}.dropzone.dz-clickable .dz-message,.dropzone.dz-clickable .dz-message *{cursor:pointer}.dropzone.dz-started .dz-message{display:none}.dropzone.dz-drag-hover{border-style:solid}.dropzone.dz-drag-hover .dz-message{opacity:.5}.dropzone .dz-message{text-align:center;margin:3em 0}.dropzone .dz-message .dz-button{background:none;color:inherit;border:none;padding:0;font:inherit;cursor:pointer;outline:inherit}.dropzone .dz-preview{position:relative;display:inline-block;vertical-align:top;margin:16px;min-height:100px}.dropzone .dz-preview:hover{z-index:1000}.dropzone .dz-preview:hover .dz-details{opacity:1}.dropzone .dz-preview.dz-file-preview .dz-image{border-radius:20px;background:#999;background:linear-gradient(to bottom, #eee, #ddd)}.dropzone .dz-preview.dz-file-preview .dz-details{opacity:1}.dropzone .dz-preview.dz-image-preview{background:#fff}.dropzone .dz-preview.dz-image-preview .dz-details{transition:opacity .2s linear}.dropzone .dz-preview .dz-remove{font-size:14px;text-align:center;display:block;cursor:pointer;border:none}.dropzone .dz-preview .dz-remove:hover{text-decoration:underline}.dropzone .dz-preview:hover .dz-details{opacity:1}.dropzone .dz-preview .dz-details{z-index:20;position:absolute;top:0;left:0;opacity:0;font-size:13px;min-width:100%;max-width:100%;padding:2em 1em;text-align:center;color:rgba(0,0,0,.9);line-height:150%}.dropzone .dz-preview .dz-details .dz-size{margin-bottom:1em;font-size:16px}.dropzone .dz-preview .dz-details .dz-filename{white-space:nowrap}.dropzone .dz-preview .dz-details .dz-filename:hover span{border:1px solid rgba(200,200,200,.8);background-color:rgba(255,255,255,.8)}.dropzone .dz-preview .dz-details .dz-filename:not(:hover){overflow:hidden;text-overflow:ellipsis}.dropzone .dz-preview .dz-details .dz-filename:not(:hover) span{border:1px solid transparent}.dropzone .dz-preview .dz-details .dz-filename span,.dropzone .dz-preview .dz-details .dz-size span{background-color:rgba(255,255,255,.4);padding:0 .4em;border-radius:3px}.dropzone .dz-preview:hover .dz-image img{transform:scale(1.05, 1.05);filter:blur(8px)}.dropzone .dz-preview .dz-image{border-radius:20px;overflow:hidden;width:120px;height:120px;position:relative;display:block;z-index:10}.dropzone .dz-preview .dz-image img{display:block}.dropzone .dz-preview.dz-success .dz-success-mark{animation:passing-through 3s cubic-bezier(0.77, 0, 0.175, 1)}.dropzone .dz-preview.dz-error .dz-error-mark{opacity:1;animation:slide-in 3s cubic-bezier(0.77, 0, 0.175, 1)}.dropzone .dz-preview .dz-success-mark,.dropzone .dz-preview .dz-error-mark{pointer-events:none;opacity:0;z-index:500;position:absolute;display:block;top:50%;left:50%;margin-left:-27px;margin-top:-27px;background:rgba(0,0,0,.8);border-radius:50%}.dropzone .dz-preview .dz-success-mark svg,.dropzone .dz-preview .dz-error-mark svg{display:block;width:54px;height:54px;fill:#fff}.dropzone .dz-preview.dz-processing .dz-progress{opacity:1;transition:all .2s linear}.dropzone .dz-preview.dz-complete .dz-progress{opacity:0;transition:opacity .4s ease-in}.dropzone .dz-preview:not(.dz-processing) .dz-progress{animation:pulse 6s ease infinite}.dropzone .dz-preview .dz-progress{opacity:1;z-index:1000;pointer-events:none;position:absolute;height:20px;top:50%;margin-top:-10px;left:15%;right:15%;border:3px solid rgba(0,0,0,.8);background:rgba(0,0,0,.8);border-radius:10px;overflow:hidden}.dropzone .dz-preview .dz-progress .dz-upload{background:#fff;display:block;position:relative;height:100%;width:0;transition:width 300ms ease-in-out;border-radius:17px}.dropzone .dz-preview.dz-error .dz-error-message{display:block}.dropzone .dz-preview.dz-error:hover .dz-error-message{opacity:1;pointer-events:auto}.dropzone .dz-preview .dz-error-message{pointer-events:none;z-index:1000;position:absolute;display:block;display:none;opacity:0;transition:opacity .3s ease;border-radius:8px;font-size:13px;top:130px;left:-10px;width:140px;background:#b10606;padding:.5em 1em;color:#fff}.dropzone .dz-preview .dz-error-message:after{content:"";position:absolute;top:-6px;left:64px;width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:6px solid #b10606}/*# sourceMappingURL=dropzone.css.map */
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+475
View File
@@ -0,0 +1,475 @@
import React, { useState, useEffect, useCallback } from 'react';
import ReactDOM from 'react-dom/client';
// Badge Component
const Badge = ({ children, variant = "default", className = "" }) => {
const variants = {
default: "bg-gray-100 text-gray-800",
link: "bg-blue-100 text-blue-800",
page: "bg-green-100 text-green-800",
post: "bg-purple-100 text-purple-800"
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${variants[variant]} ${className}`}>
{children}
</span>
);
};
// Card Component
const Card = ({ children, className = "", hover = false }) => (
<div className={`bg-white rounded-lg shadow-sm border border-gray-200 ${hover ? 'hover:shadow-md transition-shadow duration-200' : ''} ${className}`}>
{children}
</div>
);
// Input Component
const Input = ({ className = "", ...props }) => (
<input
className={`flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
{...props}
/>
);
// Select Component
const Select = ({ children, className = "", ...props }) => (
<select
className={`flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
{...props}
>
{children}
</select>
);
// Button Component
const Button = ({ children, variant = "default", className = "", ...props }) => {
const variants = {
default: "bg-blue-600 text-white hover:bg-blue-700",
outline: "border-2 border-gray-300 bg-white hover:bg-gray-50",
ghost: "hover:bg-gray-100"
};
return (
<button
className={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium ring-offset-white transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${variants[variant]} ${className}`}
{...props}
>
{children}
</button>
);
};
// Skeleton Loader
const Skeleton = ({ className = "" }) => (
<div className={`shimmer rounded ${className}`}></div>
);
// Search Result Item Component
const SearchResultItem = ({ result }) => {
const typeIcons = {
link: "fa-link",
page: "fa-file-alt",
post: "fa-newspaper"
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
return (
<Card hover className="p-4">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 mt-1">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
result.type === 'link' ? 'bg-blue-100 text-blue-600' :
result.type === 'page' ? 'bg-green-100 text-green-600' :
'bg-purple-100 text-purple-600'
}`}>
<i className={`fas ${typeIcons[result.type]}`}></i>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<a
href={result.type === 'link' ? result.url : result.detail_url || result.url}
className="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors truncate"
target={result.type === 'link' ? '_blank' : '_self'}
rel={result.type === 'link' ? 'noopener noreferrer' : ''}
>
{result.title}
</a>
<Badge variant={result.type}>
{result.type}
</Badge>
</div>
{result.type === 'link' && result.original_url && (
<a
href={result.original_url}
className="text-sm text-gray-600 hover:text-gray-900 truncate block mb-2"
target="_blank"
rel="noopener noreferrer"
>
<i className="fas fa-external-link-alt mr-1"></i>
{result.original_url}
</a>
)}
{(result.description || result.summary) && (
<p className="text-sm text-gray-600 line-clamp-2 mb-2">
{result.description || result.summary}
</p>
)}
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>
<i className="fas fa-calendar mr-1"></i>
{formatDate(result.created_at)}
</span>
{result.click_count !== undefined && (
<span>
<i className="fas fa-mouse-pointer mr-1"></i>
{result.click_count} clicks
</span>
)}
</div>
{result.tags && result.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{result.tags.map(tag => (
<Badge key={tag.id} variant="default" className="text-xs">
<i className="fas fa-tag mr-1"></i>
{tag.name}
</Badge>
))}
</div>
)}
</div>
<div className="flex-shrink-0">
<a
href={result.type === 'link' ? `/link/${result.id}/edit/` : result.type === 'page' ? `/ui/pages/${result.id}/edit/` : `/ui/posts/${result.id}/edit/`}
className="text-gray-400 hover:text-blue-600 transition-colors"
title="Edit"
>
<i className="fas fa-edit"></i>
</a>
</div>
</div>
</Card>
);
};
// Main Search App Component
const SearchApp = () => {
const [query, setQuery] = useState('');
const [type, setType] = useState('');
const [sort, setSort] = useState('relevance');
const [isVector, setIsVector] = useState(false);
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [error, setError] = useState(null);
const perPage = 20;
const performSearch = useCallback(async (searchQuery, searchType, searchSort, searchPage) => {
if (!searchQuery.trim()) {
setResults([]);
setTotal(0);
return;
}
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
q: searchQuery,
type: searchType,
sort: searchSort,
page: searchPage,
per_page: perPage
});
const endpoint = isVector ? "/search/api/vector/" : "/search/api/v2/";
const response = await fetch(`${endpoint}?${params}`);
const data = await response.json();
if (response.ok) {
setResults(data.results);
setTotal(data.total);
} else {
setError(data.error || 'Search failed');
setResults([]);
}
} catch (err) {
setError('Network error. Please try again.');
setResults([]);
} finally {
setLoading(false);
}
}, [isVector]);
const handleSearch = useCallback((e) => {
e.preventDefault();
setPage(1);
performSearch(query, type, sort, 1);
}, [query, type, sort, performSearch]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const urlQuery = params.get('q') || '';
const urlType = params.get('type') || '';
const urlSort = params.get('sort') || 'relevance';
setQuery(urlQuery);
setType(urlType);
setSort(urlSort);
if (urlQuery) {
performSearch(urlQuery, urlType, urlSort, 1);
}
}, [performSearch]);
useEffect(() => {
if (query) {
const params = new URLSearchParams({
q: query,
...(type && { type }),
...(sort !== 'relevance' && { sort })
});
window.history.replaceState({}, '', `?${params}`);
}
}, [query, type, sort]);
const handlePageChange = (newPage) => {
setPage(newPage);
performSearch(query, type, sort, newPage);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const totalPages = Math.ceil(total / perPage);
return (
<div className="min-h-screen py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="mb-8 text-center">
<a href="/" className="inline-block hover:opacity-80 transition-opacity">
<h1 className="text-4xl font-bold text-gray-900 mb-2 flex items-center justify-center gap-3">
<i className="fas fa-search text-blue-600"></i>
Advanced Search
</h1>
</a>
<p className="text-gray-600">Search through all Links, Pages, and Posts</p>
</div>
{/* Search Form */}
<Card className="p-6 mb-8 glass">
<form onSubmit={handleSearch} className="space-y-4">
{/* Search Input */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Search Query
</label>
<div className="relative">
<i className="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
<Input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Enter keywords to search..."
className="pl-10"
/>
</div>
</div>
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Filter by Type
</label>
<Select value={type} onChange={(e) => setType(e.target.value)}>
<option value="">All Types</option>
<option value="link">Links</option>
<option value="page">Pages</option>
<option value="post">Posts</option>
</Select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sort By
</label>
<div className="flex items-center gap-2 mb-2">
<label className="text-sm font-medium text-gray-700">AI Vector Search</label>
<input
type="checkbox"
checked={isVector}
onChange={(e) => setIsVector(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
</div>
<Select value={sort} onChange={(e) => setSort(e.target.value)} disabled={isVector}>
<option value="relevance">Most Relevant</option>
<option value="newest">Newest First</option>
<option value="oldest">Oldest First</option>
</Select>
</div>
</div>
{/* Search Button */}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<>
<i className="fas fa-spinner fa-spin mr-2"></i>
Searching...
</>
) : (
<>
<i className="fas fa-search mr-2"></i>
Search
</>
)}
</Button>
</form>
</Card>
{/* Results */}
{query && (
<div>
{/* Results Header */}
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
{loading ? (
'Searching...'
) : error ? (
<span className="text-red-600">
<i className="fas fa-exclamation-circle mr-2"></i>
{error}
</span>
) : (
<>
Search results for: <span className="text-blue-600">"{query}"</span>
{total > 0 && (
<span className="text-gray-500 text-sm ml-2">
({total} {total === 1 ? 'result' : 'results'})
</span>
)}
</>
)}
</h2>
</div>
{/* Loading Skeletons */}
{loading && (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<Card key={i} className="p-4">
<div className="flex items-start gap-4">
<Skeleton className="w-10 h-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
</Card>
))}
</div>
)}
{/* Results List */}
{!loading && !error && results.length > 0 && (
<div className="space-y-4">
{results.map(result => (
<SearchResultItem key={`${result.type}-${result.id}`} result={result} />
))}
</div>
)}
{/* No Results */}
{!loading && !error && results.length === 0 && query && (
<Card className="p-12 text-center">
<i className="fas fa-search text-6xl text-gray-300 mb-4"></i>
<h3 className="text-lg font-medium text-gray-900 mb-2">No results found</h3>
<p className="text-gray-500">Try adjusting your search terms or filters</p>
</Card>
)}
{/* Pagination */}
{!loading && totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<Button
variant="outline"
onClick={() => handlePageChange(page - 1)}
disabled={page === 1}
>
<i className="fas fa-chevron-left mr-2"></i>
Previous
</Button>
<div className="flex items-center gap-2">
{[...Array(Math.min(5, totalPages))].map((_, i) => {
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<Button
key={pageNum}
variant={page === pageNum ? "default" : "outline"}
onClick={() => handlePageChange(pageNum)}
className="w-10 h-10 p-0"
>
{pageNum}
</Button>
);
})}
</div>
<Button
variant="outline"
onClick={() => handlePageChange(page + 1)}
disabled={page === totalPages}
>
Next
<i className="fas fa-chevron-right ml-2"></i>
</Button>
</div>
)}
</div>
)}
{/* Empty State */}
{!query && (
<Card className="p-12 text-center glass">
<i className="fas fa-search text-6xl text-blue-200 mb-4"></i>
<h3 className="text-xl font-medium text-gray-900 mb-2">Start Searching</h3>
<p className="text-gray-600 max-w-md mx-auto">
Enter a search query to find links, pages, and posts.
You can search by title, content, URL, or tags.
</p>
</Card>
)}
</div>
</div>
);
};
// Mount app
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<SearchApp />);
+5 -1
View File
@@ -1,6 +1,10 @@
module.exports = {
content: [
// ...
'./templates/**/*.html',
'./links/templates/**/*.html',
'./new_theme/templates/**/*.html',
'./static_src/**/*.{js,jsx}',
'./**/*.js',
],
theme: {
extend: {},
+4 -3
View File
@@ -7,7 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans "GoLinks" %}</title>
<link href="{% static 'css/dist/styles.css' %}" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.7.2/css/all.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link rel="shortcut icon" type="image/x-icon" href="{% static 'favicon.ico' %}">
<style>
@@ -239,9 +239,10 @@
{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.9/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
<script src="{% static 'js/navbar.js' %}"></script>
<script>
$(document).ready(function() {
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'static/dist',
emptyOutDir: false,
rollupOptions: {
input: {
search: 'static_src/search.jsx',
},
output: {
entryFileNames: '[name].js',
chunkFileNames: '[name]-[hash].js',
assetFileNames: '[name][extname]',
},
},
},
});