Add logic to display thumbnails

This commit is contained in:
2024-11-04 15:20:18 +11:00
parent c76e47590a
commit ea08f337d7
27 changed files with 388 additions and 230 deletions
+23
View File
@@ -0,0 +1,23 @@
FROM python:3.12-slim
# Install Chrome and dependencies
RUN apt-get update && apt-get install -y \
wget \
gnupg \
chromium \
chromium-driver \
&& rm -rf /var/lib/apt/lists/*
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# Set work directory
WORKDIR /app
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy project
COPY . .
+31
View File
@@ -180,3 +180,34 @@ Flower provides:
- Runtime
- Result
- Stack traces for failed tasks
## Why we have `core` folder and what it is?
The core folder is the Django project's root configuration directory, created when you first run django-admin startproject core. It serves several essential purposes:
Project-wide settings (settings.py):
Database configuration
Installed apps
Middleware
Static/media files settings
Other global settings
Root URL configuration (urls.py):
Main URL routing
Admin site URLs
Including app-specific URLs
3. WSGI/ASGI configuration:
For production deployment
Server integration
Celery configuration (celery.py):
Project-wide Celery settings
Task discovery
We can't completely remove it because Django requires this project-level configuration. The links folder is an app within the project, and you could have multiple apps in one project.
Think of it this way:
core/ = Project configuration (can contain multiple apps)
links/ = One specific app's code
This separation allows you to:
1. Have multiple apps in one project
Reuse apps across different projects
Keep project-wide settings separate from app-specific code
If you want to simplify, you could rename core to something shorter like config or core, but you can't eliminate it entirely as it's a fundamental part of Django's project structure.
Would you like me to show you how to rename it to something shorter and cleaner?
+1 -1
View File
@@ -2,4 +2,4 @@
set -eu
source 3.12/bin/activate
echo "Starting Django development server..."
celery -A url_manager beat -l info
celery -A core beat -l info
Binary file not shown.
+2 -2
View File
@@ -1,8 +1,8 @@
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'url_manager.settings')
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
app = Celery('url_manager')
app = Celery('core')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
+11 -4
View File
@@ -22,12 +22,12 @@ INSTALLED_APPS = [
'markdown', # 只需要基本的markdown包
]
ROOT_URLCONF = 'url_manager.urls'
ROOT_URLCONF = 'core.urls'
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'url_manager.middleware.CustomLocaleMiddleware', # 替换原来的 LocaleMiddleware
'core.middleware.CustomLocaleMiddleware', # 替换原来的 LocaleMiddleware
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
@@ -97,7 +97,7 @@ USE_TZ = True
# ... 其他设置 ...
ROOT_URLCONF = 'url_manager.urls'
ROOT_URLCONF = 'core.urls'
# ... 其他设置 ...
@@ -176,7 +176,7 @@ LOCALE_INDEPENDENT_PATHS = [
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'url_manager.middleware.CustomLocaleMiddleware', # 使用自定义中间件
'core.middleware.CustomLocaleMiddleware', # 使用自定义中间件
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
@@ -212,3 +212,10 @@ CELERY_BEAT_SCHEDULE = {
'schedule': 60.0, # 每60秒运行一次
},
}
# Media files configuration
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# Ensure media files are served in all environments
SERVE_MEDIA = True
+6 -2
View File
@@ -1,9 +1,10 @@
from django.contrib import admin
from django.urls import path, include
from django.urls import path, include, re_path
from django.conf.urls.i18n import i18n_patterns
from django.conf import settings
from django.conf.urls.static import static
from links.views import LinkDetailView, LinkUpdateView, CustomLinkView
from django.views.static import serve
urlpatterns = [
path('i18n/', include('django.conf.urls.i18n')),
@@ -11,8 +12,11 @@ urlpatterns = [
path('link/<int:pk>/', LinkDetailView.as_view(), name='link_detail'),
path('link/<int:pk>/edit/', LinkUpdateView.as_view(), name='link_update'),
path('custom/<slug:alias>/', CustomLinkView.as_view(), name='custom_link'),
# Serve media files directly
re_path(r'^media/(?P<path>.*)$', serve, {
'document_root': settings.MEDIA_ROOT,
}),
]
urlpatterns += i18n_patterns(
path('admin/', admin.site.urls),
path('', include('links.urls')),
+1 -1
View File
@@ -1,6 +1,6 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'url_manager.settings')
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_wsgi_application()
BIN
View File
Binary file not shown.
+18 -9
View File
@@ -1,3 +1,5 @@
version: '3.8'
services:
web:
build: .
@@ -6,10 +8,11 @@ services:
python manage.py runserver 0.0.0.0:8000"
volumes:
- .:/app
- ./media:/app/media
ports:
- "8000:8000"
environment:
- DJANGO_SETTINGS_MODULE=url_manager.settings
- DJANGO_SETTINGS_MODULE=core.settings
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
@@ -19,27 +22,29 @@ services:
- app-network
celery_worker:
build: .
command: celery -A url_manager worker --loglevel=info
build:
context: .
dockerfile: Dockerfile.worker
command: celery -A core worker --loglevel=info
volumes:
- .:/app
- ./media:/app/media
environment:
- DJANGO_SETTINGS_MODULE=url_manager.settings
- DJANGO_SETTINGS_MODULE=core.settings
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
- redis
- web
networks:
- app-network
celery_beat:
build: .
command: celery -A url_manager beat --loglevel=info
command: celery -A core beat --loglevel=info
volumes:
- .:/app
environment:
- DJANGO_SETTINGS_MODULE=url_manager.settings
- DJANGO_SETTINGS_MODULE=core.settings
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
@@ -67,13 +72,13 @@ services:
flower:
build: .
command: celery -A url_manager flower --port=5555
command: celery -A core flower --port=5555
volumes:
- .:/app
ports:
- "5555:5555"
environment:
- DJANGO_SETTINGS_MODULE=url_manager.settings
- DJANGO_SETTINGS_MODULE=core.settings
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
@@ -82,6 +87,10 @@ services:
networks:
- app-network
volumes:
media_volume:
static_volume:
networks:
app-network:
driver: bridge
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
mkdir -p media/screenshots
chmod -R 777 media
@@ -0,0 +1,18 @@
# Generated by Django 5.0.9 on 2024-11-04 03:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('links', '0011_page_error_message_page_last_retry_at_and_more'),
]
operations = [
migrations.AddField(
model_name='page',
name='screenshot_path',
field=models.CharField(blank=True, max_length=255),
),
]
+11 -1
View File
@@ -4,6 +4,8 @@ from django.urls import reverse
import logging
import re
from django.utils import timezone
from django.conf import settings
import os
logger = logging.getLogger(__name__)
@@ -151,6 +153,9 @@ class Page(models.Model):
last_retry_at = models.DateTimeField(null=True, blank=True)
error_message = models.TextField(blank=True)
# New field for screenshot
screenshot_path = models.CharField(max_length=255, blank=True)
class Meta:
ordering = ['-updated_at']
verbose_name = _('Page')
@@ -163,6 +168,11 @@ class Page(models.Model):
return reverse('page-detail', kwargs={'pk': self.pk})
def needs_processing(self):
return (not self.title or not self.summary) and \
return (not self.title or not self.summary or not self.screenshot_path) and \
self.process_status != self.ProcessStatus.FAILED and \
self.retry_count < 3
def get_screenshot_url(self):
if self.screenshot_path:
return f'/media/{self.screenshot_path}'
return None
+49 -11
View File
@@ -3,7 +3,7 @@ from django.urls import reverse_lazy
from django.http import JsonResponse
import requests
from bs4 import BeautifulSoup
from urllib.parse import urlparse
from urllib.parse import urlparse, quote
import re
from rest_framework import viewsets, status
from rest_framework.response import Response
@@ -11,6 +11,13 @@ from rest_framework.pagination import PageNumberPagination
from .models import Page
from .forms import PageForm
from .serializers import PageSerializer
import os
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from django.conf import settings
import time
import base64
from django.core.files.base import ContentFile
class PageListView(ListView):
model = Page
@@ -29,7 +36,25 @@ class PageCreateView(CreateView):
success_url = reverse_lazy('page-list')
def form_valid(self, form):
# 如果标题和摘要都已经自动填充,则将状态设置为已完成
# Handle screenshot data
screenshot_data = self.request.POST.get('screenshot_data')
if screenshot_data and screenshot_data.startswith('data:image/png;base64,'):
# Extract the base64 data
image_data = screenshot_data.split(',')[1]
# Create filename
filename = f"page_screenshot_{form.instance.id}.png"
# Save the image
image_content = ContentFile(base64.b64decode(image_data))
form.instance.screenshot_path = f'screenshots/{filename}'
# Ensure directory exists
os.makedirs(os.path.join(settings.MEDIA_ROOT, 'screenshots'), exist_ok=True)
# Save the file
with open(os.path.join(settings.MEDIA_ROOT, form.instance.screenshot_path), 'wb') as f:
f.write(base64.b64decode(image_data))
# Set status
if form.instance.title and form.instance.summary:
form.instance.process_status = Page.ProcessStatus.COMPLETED
else:
@@ -48,12 +73,30 @@ class PageDeleteView(DeleteView):
template_name = 'links/page_confirm_delete.html'
success_url = reverse_lazy('page-list')
def take_screenshot(url):
chrome_options = Options()
chrome_options.add_argument('--headless')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-dev-shm-usage')
chrome_options.add_argument('--window-size=1920,1080')
driver = webdriver.Chrome(options=chrome_options)
try:
driver.set_page_load_timeout(10) # Set page load timeout
driver.get(url)
time.sleep(2) # Wait for any dynamic content
# Take screenshot and convert to base64
screenshot = driver.get_screenshot_as_base64()
return f"data:image/png;base64,{screenshot}"
finally:
driver.quit()
def fetch_page_info(request):
url = request.GET.get('url')
if not url:
return JsonResponse({'error': 'URL is required'}, status=400)
# 移除URL开头可能的@符号
url = url.lstrip('@')
try:
@@ -63,14 +106,13 @@ def fetch_page_info(request):
'Accept-Language': 'en-US,en;q=0.5',
}
response = requests.get(url, headers=headers, timeout=10, verify=False)
response = requests.get(url, headers=headers, timeout=5, verify=False) # Reduced timeout to 5 seconds
if response.encoding == 'ISO-8859-1':
response.encoding = response.apparent_encoding or 'utf-8'
soup = BeautifulSoup(response.text, 'html.parser')
# 获取标题
# Get title
title = None
if soup.title:
title = soup.title.string
@@ -85,7 +127,7 @@ def fetch_page_info(request):
else:
title = urlparse(url).netloc
# 获取描述
# Get description
description = None
meta_desc = soup.find('meta', {'name': 'description'}) or soup.find('meta', {'property': 'og:description'})
if meta_desc:
@@ -113,10 +155,6 @@ def fetch_page_info(request):
'summary': description
})
except requests.exceptions.RequestException as e:
return JsonResponse({
'error': f'Failed to fetch page: {str(e)}'
}, status=400)
except Exception as e:
return JsonResponse({
'error': f'Error processing page: {str(e)}'
+45 -3
View File
@@ -5,6 +5,13 @@ import requests
from bs4 import BeautifulSoup
import logging
from .models import Page
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
import os
from django.conf import settings
from urllib.parse import quote
import time
from core.celery import app
logger = logging.getLogger(__name__)
@@ -19,16 +26,43 @@ def fibonacci(n):
a, b = b, a + b
return b
def take_screenshot(url, page_id):
chrome_options = Options()
chrome_options.add_argument('--headless')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-dev-shm-usage')
chrome_options.add_argument('--window-size=1920,1080')
driver = webdriver.Chrome(options=chrome_options)
try:
driver.get(url)
# Wait for page to load
time.sleep(5)
# Create screenshots directory if it doesn't exist
screenshots_dir = os.path.join(settings.MEDIA_ROOT, 'screenshots')
os.makedirs(screenshots_dir, exist_ok=True)
# Create a safe filename using the page ID and encoded URL
safe_url = quote(url, safe='')[:50] # Limit URL length in filename
filename = f"page_{page_id}_{safe_url}.png"
filepath = os.path.join('screenshots', filename)
full_path = os.path.join(settings.MEDIA_ROOT, filepath)
# Take screenshot
driver.save_screenshot(full_path)
return filepath
finally:
driver.quit()
@shared_task(bind=True, max_retries=3)
def process_page(self, page_id):
try:
page = Page.objects.get(id=page_id)
# Check if page still needs processing
if not page.needs_processing():
return
# Update status to processing
page.process_status = Page.ProcessStatus.PROCESSING
page.save()
@@ -38,6 +72,7 @@ def process_page(self, page_id):
'Accept-Language': 'en-US,en;q=0.5',
}
# Fetch page content
response = requests.get(page.url, headers=headers, timeout=10, verify=False)
response.raise_for_status()
@@ -80,6 +115,14 @@ def process_page(self, page_id):
if description:
page.summary = description[:500]
# Take screenshot if not present
if not page.screenshot_path:
try:
screenshot_path = take_screenshot(page.url, page.id)
page.screenshot_path = screenshot_path
except Exception as e:
logger.error(f"Failed to take screenshot for page {page_id}: {e}")
page.process_status = Page.ProcessStatus.COMPLETED
page.save()
logger.info(f"Successfully processed page {page_id}")
@@ -94,7 +137,6 @@ def process_page(self, page_id):
logger.error(f"Failed to process page {page_id} after 3 retries: {exc}")
else:
page.process_status = Page.ProcessStatus.PENDING
# Schedule retry with fibonacci backoff
retry_delay = fibonacci(page.retry_count)
logger.info(f"Scheduling retry for page {page_id} in {retry_delay} seconds")
self.retry(exc=exc, countdown=retry_delay)
+18
View File
@@ -1,6 +1,7 @@
{% extends 'base.html' %}
{% load i18n %}
{% load markdown_extras %}
{% load static %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
@@ -108,6 +109,23 @@
</div>
</div>
{% endif %}
<!-- Screenshot -->
<div class="mb-6">
<h2 class="text-sm font-medium text-gray-500 mb-2">{% trans "Screenshot" %}</h2>
<div class="rounded-lg overflow-hidden border border-gray-200">
{% if page.screenshot_path %}
<img src="{{ page.get_screenshot_url }}"
alt="Page screenshot"
class="w-full h-auto"
loading="lazy">
{% else %}
<img src="{% static 'images/default-screenshot.png' %}"
alt="Default screenshot"
class="w-full h-auto">
{% endif %}
</div>
</div>
</div>
</div>
</div>
+49 -21
View File
@@ -3,14 +3,32 @@
{% block extra_css %}
<style>
.loading-spinner {
.overlay {
display: none;
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #3498db;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.loading-container {
background: white;
padding: 2rem;
border-radius: 0.5rem;
text-align: center;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 1rem auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
@@ -20,18 +38,25 @@
{% endblock %}
{% block content %}
<!-- Loading Overlay -->
<div id="loadingOverlay" class="overlay">
<div class="loading-container">
<div class="loading-spinner"></div>
<p class="text-gray-700">{% trans "Fetching page information..." %}</p>
<p class="text-sm text-gray-500">{% trans "This may take a few seconds" %}</p>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="bg-white shadow-sm rounded-lg overflow-hidden">
<div class="px-4 py-5 sm:p-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">
<h1 class="text-2xl font-bold text-gray-900 mb-6">
{% if form.instance.pk %}
{% trans "Edit Page" %}
{% else %}
{% trans "New Page" %}
{% endif %}
</h1>
</div>
<form method="post" class="space-y-6">
{% csrf_token %}
@@ -40,17 +65,13 @@
<!-- URL Field -->
<div>
<label for="{{ form.url.id_for_label }}" class="block text-sm font-medium text-gray-700">
{{ form.url.label }} <span class="text-red-500">*</span>
{{ form.url.label }}
</label>
<div class="mt-1 relative">
<div class="mt-1">
<input type="url" name="{{ form.url.name }}" id="{{ form.url.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"
value="{{ form.url.value|default:'' }}"
placeholder="https://example.com"
required>
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
<div class="loading-spinner" id="urlSpinner"></div>
</div>
placeholder="https://example.com">
</div>
<div id="urlError" class="mt-2 text-sm text-red-600 hidden"></div>
{% if form.url.errors %}
@@ -130,8 +151,8 @@ let fetchTimeout;
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
const summaryInput = document.getElementById('{{ form.summary.id_for_label }}');
const spinner = document.getElementById('urlSpinner');
const errorDiv = document.getElementById('urlError');
const loadingOverlay = document.getElementById('loadingOverlay');
urlInput.addEventListener('input', function(e) {
clearTimeout(fetchTimeout);
@@ -139,7 +160,6 @@ urlInput.addEventListener('input', function(e) {
if (!urlInput.value) return;
// 等待用户停止输入500ms后再发起请求
fetchTimeout = setTimeout(() => {
if (urlInput.checkValidity()) {
fetchPageInfo(urlInput.value);
@@ -148,15 +168,23 @@ urlInput.addEventListener('input', function(e) {
});
async function fetchPageInfo(url) {
spinner.style.display = 'block';
loadingOverlay.style.display = 'flex';
errorDiv.classList.add('hidden');
// Set timeout
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Request timed out')), 5000); // 5 second timeout
});
try {
const response = await fetch(`/fetch-page-info/?url=${encodeURIComponent(url)}`);
const response = await Promise.race([
fetch(`/fetch-page-info/?url=${encodeURIComponent(url)}`),
timeoutPromise
]);
const data = await response.json();
if (response.ok) {
// 只在字段为空时填充数据
if (!titleInput.value) {
titleInput.value = data.title || '';
}
@@ -170,7 +198,7 @@ async function fetchPageInfo(url) {
errorDiv.textContent = error.message || 'Failed to fetch page information';
errorDiv.classList.remove('hidden');
} finally {
spinner.style.display = 'none';
loadingOverlay.style.display = 'none';
}
}
</script>
+49 -20
View File
@@ -1,5 +1,6 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
@@ -13,19 +14,41 @@
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<ul class="divide-y divide-gray-200">
{% for page in pages %}
<li>
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex-1">
<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">
<li class="p-4">
<div class="flex space-x-4">
<!-- Thumbnail Container -->
<div class="flex-shrink-0 w-[200px] h-[200px] rounded-lg overflow-hidden bg-gray-100">
{% if page.screenshot_path %}
<img src="{{ page.get_screenshot_url }}"
alt="Page thumbnail"
class="w-[200px] h-[200px] object-cover"
loading="lazy">
{% else %}
<img src="{% static 'images/default-screenshot.png' %}"
alt="Default thumbnail"
class="w-[200px] h-[200px] object-cover">
{% endif %}
</div>
<!-- Content Container -->
<div class="flex-1 min-w-0 flex flex-col">
<!-- Header -->
<div class="flex justify-between items-start">
<!-- Title and URL -->
<div class="min-w-0 flex-1 pr-4">
<h2 class="text-lg font-medium text-blue-600 hover:text-blue-800 truncate">
<a href="{{ page.get_absolute_url }}">{{ page.title|default:"Untitled" }}</a>
</h2>
<a href="{{ page.url }}"
target="_blank"
rel="noopener noreferrer"
class="mt-1 text-sm text-gray-600 hover:text-gray-900 break-all line-clamp-1">
{{ page.url }}
</a>
</div>
<div class="flex items-center space-x-4">
<!-- Status and Actions -->
<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">
@@ -37,10 +60,7 @@
</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 %}
{% 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">
@@ -50,13 +70,15 @@
<!-- Action Buttons -->
<div class="flex space-x-2">
<a href="{% url 'page-update' page.pk %}" class="text-gray-600 hover:text-gray-900">
<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="text-red-600 hover:text-red-800">
<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"/>
@@ -65,13 +87,18 @@
</div>
</div>
</div>
<div class="mt-2">
<p class="text-gray-600">{{ page.summary }}</p>
<!-- 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-4 text-center text-gray-500">
@@ -85,7 +112,8 @@
<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">
<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 %}
@@ -95,7 +123,8 @@
</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">
<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 %}
+2 -1
View File
@@ -1,5 +1,6 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from django.urls import re_path
from . import views
router = DefaultRouter(trailing_slash=False)
@@ -21,7 +22,7 @@ urlpatterns = [
path('ui/help/', views.HelpView.as_view(), name='help'),
path('export/', views.export_links, name='export_links'),
# Pages UI
# Pages
path('ui/pages/', views.PageListView.as_view(), name='page-list'),
path('ui/pages/new/', views.PageCreateView.as_view(), name='page-create'),
path('ui/pages/<int:pk>/', views.PageDetailView.as_view(), name='page-detail'),
+2 -53
View File
@@ -18,57 +18,6 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/forms.py:6
msgid "Search files"
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:5
msgid "Select an image to embed"
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:27
msgid "Browse for the image you want, then click 'Embed Image' to continue..."
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:29
msgid ""
"No images found. Upload images using the 'Image Button' dialog's 'Upload' "
"tab."
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:50
msgid "Images in: "
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:62
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:80
msgid "Embed Image"
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:146
msgid "Play Slideshow"
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:147
msgid "Pause Slideshow"
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:148
msgid "&lsaquo; Previous Photo"
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:149
msgid "Next Photo &rsaquo;"
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:150
msgid "Next &rsaquo;"
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:151
msgid "&lsaquo; Prev"
msgstr ""
#: 3.12/lib/python3.12/site-packages/click/_termui_impl.py:518
#, python-brace-format
msgid "{editor}: Editing failed"
@@ -1842,10 +1791,10 @@ msgstr ""
msgid "No results found"
msgstr ""
#: url_manager/settings.py:81
#: core/settings.py:81
msgid "English"
msgstr ""
#: url_manager/settings.py:82
#: core/settings.py:82
msgid "Simplified Chinese"
msgstr ""
+2 -55
View File
@@ -18,59 +18,6 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/forms.py:6
#, fuzzy
#| msgid "Search Links"
msgid "Search files"
msgstr "搜索链接"
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:5
msgid "Select an image to embed"
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:27
msgid "Browse for the image you want, then click 'Embed Image' to continue..."
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:29
msgid ""
"No images found. Upload images using the 'Image Button' dialog's 'Upload' "
"tab."
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:50
msgid "Images in: "
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:62
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:80
msgid "Embed Image"
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:146
msgid "Play Slideshow"
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:147
msgid "Pause Slideshow"
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:148
msgid "&lsaquo; Previous Photo"
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:149
msgid "Next Photo &rsaquo;"
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:150
msgid "Next &rsaquo;"
msgstr ""
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:151
msgid "&lsaquo; Prev"
msgstr ""
#: 3.12/lib/python3.12/site-packages/click/_termui_impl.py:518
#, python-brace-format
msgid "{editor}: Editing failed"
@@ -1894,11 +1841,11 @@ msgstr "搜索别名..."
msgid "No results found"
msgstr "未找到结果。"
#: url_manager/settings.py:81
#: core/settings.py:81
msgid "English"
msgstr "英语"
#: url_manager/settings.py:82
#: core/settings.py:82
msgid "Simplified Chinese"
msgstr "简体中文"
+3 -3
View File
@@ -2,6 +2,8 @@
case "$1" in
"start")
mkdir -p media/screenshots
chmod -R 777 media
docker-compose up -d
echo "Services started. Access:"
echo "- Web: http://localhost:8000"
@@ -27,10 +29,8 @@ case "$1" in
docker-compose exec web python manage.py shell
;;
"migrate")
docker-compose exec web python manage.py migrate
;;
"makemigrations")
docker-compose exec web python manage.py makemigrations
docker-compose exec web python manage.py migrate
;;
"tailwind-logs")
docker-compose logs -f tailwind
+1 -1
View File
@@ -6,7 +6,7 @@ import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'url_manager.settings')
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
+1
View File
@@ -17,3 +17,4 @@ djangorestframework==3.15.2
celery>=5.3.0
redis>=4.5.0
flower>=2.0.0
selenium>=4.0.0
+1 -1
View File
@@ -2,4 +2,4 @@
set -eu
source 3.12/bin/activate
echo "Starting Django development server..."
celery -A url_manager worker -l info
celery -A core worker -l info