Add pages function

This commit is contained in:
2024-11-04 11:37:00 +11:00
parent 5381c0e9ad
commit f3a3eba0a3
18 changed files with 785 additions and 14 deletions
BIN
View File
Binary file not shown.
+10 -1
View File
@@ -1,5 +1,5 @@
from django import forms
from .models import Link
from .models import Link, Page
from simplemde.fields import SimpleMDEField
from django.utils.translation import gettext_lazy as _
from django.core.validators import URLValidator
@@ -50,3 +50,12 @@ class LinkForm(forms.ModelForm):
raise forms.ValidationError(_("Text is required for Custom type."))
return cleaned_data
class PageForm(forms.ModelForm):
class Meta:
model = Page
fields = ['url', 'title', 'summary', 'content']
widgets = {
'summary': forms.Textarea(attrs={'rows': 3}),
'content': forms.Textarea(attrs={'rows': 10}),
}
+31
View File
@@ -0,0 +1,31 @@
# Generated by Django 5.0.9 on 2024-11-03 23:09
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('links', '0008_alter_link_original_url'),
]
operations = [
migrations.CreateModel(
name='Page',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.URLField(max_length=2000, verbose_name='URL')),
('title', models.CharField(max_length=200, verbose_name='Title')),
('summary', models.TextField(blank=True, verbose_name='Summary')),
('content', models.TextField(blank=True, verbose_name='Content')),
('created_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
],
options={
'verbose_name': 'Page',
'verbose_name_plural': 'Pages',
'ordering': ['-updated_at'],
},
),
]
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 5.0.9 on 2024-11-03 23:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('links', '0009_page'),
]
operations = [
migrations.AlterField(
model_name='page',
name='title',
field=models.CharField(blank=True, max_length=200, verbose_name='Title'),
),
]
+20
View File
@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
from django.urls import reverse
import logging
import re
from django.utils import timezone
logger = logging.getLogger(__name__)
@@ -125,3 +126,22 @@ class LinkChangeLog(models.Model):
def __str__(self):
return f"URL changed from {self.old_url} to {self.new_url}"
class Page(models.Model):
url = models.URLField(_('URL'), max_length=2000)
title = models.CharField(_('Title'), max_length=200, blank=True)
summary = models.TextField(_('Summary'), blank=True)
content = models.TextField(_('Content'), blank=True)
created_at = models.DateTimeField(_('Created at'), default=timezone.now)
updated_at = models.DateTimeField(_('Updated at'), auto_now=True)
class Meta:
ordering = ['-updated_at']
verbose_name = _('Page')
verbose_name_plural = _('Pages')
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse('page-detail', kwargs={'pk': self.pk})
+8
View File
@@ -0,0 +1,8 @@
from rest_framework import serializers
from .models import Page
class PageSerializer(serializers.ModelSerializer):
class Meta:
model = Page
fields = ['id', 'url', 'title', 'summary', 'content', 'created_at', 'updated_at']
read_only_fields = ['created_at', 'updated_at']
+66 -4
View File
@@ -86,9 +86,11 @@
{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto mt-10 p-4 sm:px-6 bg-white shadow-md rounded-lg">
<div class="prose prose-sm sm:prose lg:prose-lg xl:prose-xl w-full">
{% filter markdown %}
<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="prose prose-sm sm:prose lg:prose-lg xl:prose-xl w-full">
{% filter markdown %}
# Frequently Asked Questions (FAQ)
## Link Types
@@ -135,7 +137,67 @@ Custom pages allow you to create markdown-based web pages that can serve as docu
**Example Use Cases:**
Check http://go.junv.cc/custom/today/
{% endfilter %}
## API Usage
GoLinks provides RESTful APIs for programmatic access to pages. Here are the available endpoints and their usage:
### List Pages
Retrieve a paginated list of all pages:
```
GET /api/pages/
GET /api/pages/?page=2
GET /api/pages/?page_size=20
```
Response examples:
```
{
"count": 100,
"next": "http://localhost:8000/api/pages/?page=2",
"previous": null,
"results": [
{
"id": 1,
"url": "https://example.com",
"title": "Example Page",
"summary": "This is a summary",
"content": "This is the content",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
},
// ... more pages
]
}
```
### Create a new Page
```
POST /api/pages/
Content-Type: application/json
{
"url": "https://example.com",
"title": "Example Page",
"summary": "This is a summary",
"content": "This is the content"
}
```
Response example:
```
{
"id": 1,
"url": "https://example.com",
"title": "Example Page",
"summary": "This is a summary",
"content": "This is the content",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
```
{% endfilter %}
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,29 @@
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="bg-white shadow-sm rounded-lg overflow-hidden">
<div class="px-4 py-5 sm:p-6">
<h1 class="text-2xl font-bold text-gray-900 mb-6">{% trans "Delete Page" %}</h1>
<p class="text-gray-700 mb-6">
{% trans "Are you sure you want to delete this page?" %}
<strong>{{ page.title }}</strong>
</p>
<form method="post">
{% csrf_token %}
<div class="flex justify-end space-x-3">
<a href="{% url 'page-list' %}" class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50">
{% trans "Cancel" %}
</a>
<button type="submit" class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{% trans "Delete" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
{% extends 'base.html' %}
{% load i18n %}
{% load markdown_extras %}
{% block content %}
<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-3xl font-bold text-gray-900">{{ page.title }}</h1>
<div class="flex space-x-2">
<a href="{% url 'page-update' page.pk %}" class="text-blue-600 hover:text-blue-800">
{% trans "Edit" %}
</a>
<a href="{{ page.url }}" target="_blank" rel="noopener noreferrer" class="text-green-600 hover:text-green-800">
{% trans "Visit URL" %}
</a>
</div>
</div>
<div class="prose prose-sm sm:prose lg:prose-lg xl:prose-xl w-full">
{{ page.content|markdown|safe }}
</div>
<div class="mt-6 text-sm text-gray-500">
{% trans "Last updated" %}: {{ page.updated_at|date:"Y-m-d H:i" }}
</div>
</div>
</div>
</div>
{% endblock %}
+177
View File
@@ -0,0 +1,177 @@
{% extends 'base.html' %}
{% load i18n %}
{% block extra_css %}
<style>
.loading-spinner {
display: none;
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
{% endblock %}
{% block content %}
<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">
{% if form.instance.pk %}
{% trans "Edit Page" %}
{% else %}
{% trans "New Page" %}
{% endif %}
</h1>
</div>
<form method="post" class="space-y-6">
{% csrf_token %}
<div class="space-y-6">
<!-- 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>
</label>
<div class="mt-1 relative">
<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>
</div>
<div id="urlError" class="mt-2 text-sm text-red-600 hidden"></div>
{% if form.url.errors %}
<p class="mt-2 text-sm text-red-600">{{ form.url.errors.0 }}</p>
{% endif %}
</div>
<!-- Title Field -->
<div>
<label for="{{ form.title.id_for_label }}" class="block text-sm font-medium text-gray-700">
{{ form.title.label }}
</label>
<div class="mt-1">
<input type="text" name="{{ form.title.name }}" id="{{ form.title.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.title.value|default:'' }}"
placeholder="Page Title">
</div>
{% if form.title.errors %}
<p class="mt-2 text-sm text-red-600">{{ form.title.errors.0 }}</p>
{% endif %}
</div>
<!-- Summary Field -->
<div>
<label for="{{ form.summary.id_for_label }}" class="block text-sm font-medium text-gray-700">
{{ form.summary.label }}
</label>
<div class="mt-1">
<textarea name="{{ form.summary.name }}" id="{{ form.summary.id_for_label }}" rows="3"
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border border-gray-300 rounded-md"
placeholder="Brief summary of the page">{{ form.summary.value|default:'' }}</textarea>
</div>
{% if form.summary.errors %}
<p class="mt-2 text-sm text-red-600">{{ form.summary.errors.0 }}</p>
{% endif %}
</div>
<!-- Content Field -->
<div>
<label for="{{ form.content.id_for_label }}" class="block text-sm font-medium text-gray-700">
{{ form.content.label }}
</label>
<div class="mt-1">
<textarea name="{{ form.content.name }}" id="{{ form.content.id_for_label }}" rows="10"
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border border-gray-300 rounded-md font-mono"
placeholder="Page content in Markdown format">{{ form.content.value|default:'' }}</textarea>
</div>
{% if form.content.errors %}
<p class="mt-2 text-sm text-red-600">{{ form.content.errors.0 }}</p>
{% endif %}
<p class="mt-2 text-sm text-gray-500">{% trans "Supports Markdown formatting" %}</p>
</div>
</div>
<div class="pt-5">
<div class="flex justify-end space-x-3">
<a href="{% url 'page-list' %}"
class="inline-flex justify-center py-2 px-4 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
{% trans "Cancel" %}
</a>
<button type="submit"
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
{% trans "Save" %}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
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');
urlInput.addEventListener('input', function(e) {
clearTimeout(fetchTimeout);
errorDiv.classList.add('hidden');
if (!urlInput.value) return;
// 等待用户停止输入500ms后再发起请求
fetchTimeout = setTimeout(() => {
if (urlInput.checkValidity()) {
fetchPageInfo(urlInput.value);
}
}, 500);
});
async function fetchPageInfo(url) {
spinner.style.display = 'block';
errorDiv.classList.add('hidden');
try {
const response = await fetch(`/fetch-page-info/?url=${encodeURIComponent(url)}`);
const data = await response.json();
if (response.ok) {
// 只在字段为空时填充数据
if (!titleInput.value) {
titleInput.value = data.title || '';
}
if (!summaryInput.value) {
summaryInput.value = data.summary || '';
}
} else {
throw new Error(data.error);
}
} catch (error) {
errorDiv.textContent = error.message || 'Failed to fetch page information';
errorDiv.classList.remove('hidden');
} finally {
spinner.style.display = 'none';
}
}
</script>
{% endblock %}
+81
View File
@@ -0,0 +1,81 @@
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">{% trans "Bookmark Pages" %}</h1>
<a href="{% url 'page-create' %}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">
{% trans "Add Page" %}
</a>
</div>
<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 }}
</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 space-x-2 ml-4">
<a href="{% url 'page-update' page.pk %}" class="text-gray-600 hover:text-gray-900">
<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">
<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 class="mt-2">
<p class="text-gray-600">{{ page.summary }}</p>
</div>
<div class="mt-2 text-sm text-gray-500">
{% trans "Updated" %}: {{ page.updated_at|date:"Y-m-d H:i" }}
</div>
</div>
</li>
{% empty %}
<li class="px-4 py-4 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">
{% 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 %}
+18 -3
View File
@@ -1,7 +1,12 @@
from django.urls import path
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter(trailing_slash=False)
router.register(r'pages', views.PageViewSet, basename='api-page')
urlpatterns = [
# Regular UI URLs
path('', views.LinkListView.as_view(), name='link_list'),
path('create/', views.LinkCreateView.as_view(), name='link_create'),
path('update/<int:pk>/', views.LinkUpdateView.as_view(), name='link_update'),
@@ -16,9 +21,19 @@ urlpatterns = [
path('ui/help/', views.HelpView.as_view(), name='help'),
path('export/', views.export_links, name='export_links'),
# Aliases
# Pages UI
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'),
path('ui/pages/<int:pk>/edit/', views.PageUpdateView.as_view(), name='page-update'),
path('ui/pages/<int:pk>/delete/', views.PageDeleteView.as_view(), name='page-delete'),
path('fetch-page-info/', views.fetch_page_info, name='fetch-page-info'),
# API URLs - place before catch-all alias routes
path('api/', include(router.urls)),
# Aliases - these should always be last as they're catch-all routes
path('<str:alias>/', views.redirect_to_original, name='redirect_to_original'),
path('<str:alias>/<str:param>/', views.redirect_to_original, name='redirect_to_original_with_param'),
path('alias/<str:alias>/', views.LinkDetailView.as_view(), name='link_detail_by_alias'),
]
+141 -2
View File
@@ -4,8 +4,8 @@ from django.views import View # Add this import
from django.urls import reverse_lazy, reverse # Add 'reverse' here
from django.db.models import F, Count, Q, Case, When, Value, IntegerField
from django.db.models.functions import TruncDate
from .models import Link, ClickLog, LinkChangeLog
from .forms import LinkForm
from .models import Link, ClickLog, LinkChangeLog, Page
from .forms import LinkForm, PageForm
import json
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib import messages
@@ -27,6 +27,14 @@ from django.conf import settings
from django.http import FileResponse
from wsgiref.util import FileWrapper
from django.contrib.auth.decorators import user_passes_test
from django.contrib.auth.mixins import LoginRequiredMixin
import requests
from bs4 import BeautifulSoup
from urllib.parse import urlparse
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from .serializers import PageSerializer
logger = logging.getLogger(__name__)
@@ -432,3 +440,134 @@ def export_database(request):
class HelpView(TemplateView):
template_name = 'links/help.html'
class PageListView(ListView):
model = Page
template_name = 'links/page_list.html'
context_object_name = 'pages'
paginate_by = 10
class PageDetailView(DetailView):
model = Page
template_name = 'links/page_detail.html'
class PageCreateView(CreateView):
model = Page
form_class = PageForm
template_name = 'links/page_form.html'
success_url = reverse_lazy('page-list')
class PageUpdateView(UpdateView):
model = Page
form_class = PageForm
template_name = 'links/page_form.html'
success_url = reverse_lazy('page-list')
class PageDeleteView(DeleteView):
model = Page
template_name = 'links/page_confirm_delete.html'
success_url = reverse_lazy('page-list')
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:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
}
response = requests.get(url, headers=headers, timeout=10, verify=False)
# 确保使用正确的编码
if response.encoding == 'ISO-8859-1':
response.encoding = response.apparent_encoding or 'utf-8'
soup = BeautifulSoup(response.text, 'html.parser')
# 获取标题 - 尝试多种方式
title = None
# 1. 尝试获取title标签
if soup.title:
title = soup.title.string
# 2. 尝试获取第一个h1
if not title and soup.find('h1'):
title = soup.find('h1').get_text(strip=True)
# 3. 尝试获取og:title
if not title:
og_title = soup.find('meta', property='og:title')
if og_title:
title = og_title.get('content')
# 清理标题
if title:
title = re.sub(r'\s+', ' ', title.strip())
title = title.replace(' | ', ' - ').replace(' :: ', ' - ')
else:
title = urlparse(url).netloc
# 获取描述 - 尝试多种方式
description = None
# 1. 尝试meta description
meta_desc = soup.find('meta', {'name': 'description'}) or soup.find('meta', {'property': 'og:description'})
if meta_desc:
description = meta_desc.get('content')
# 2. 如果没有meta描述,尝试获取正文内容
if not description:
# 移除script, style等标签
for tag in soup(['script', 'style', 'nav', 'header', 'footer']):
tag.decompose()
# 获取所有段落
paragraphs = soup.find_all(['p', 'div'])
for p in paragraphs:
text = p.get_text(strip=True)
if len(text) > 100: # 确保段落有足够的内容
description = text
break
# 如果还是没有描述,使用标题
if not description:
description = title
# 清理描述
description = re.sub(r'\s+', ' ', description.strip())
description = description[:500] + '...' if len(description) > 500 else description
return JsonResponse({
'title': title,
'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)}'
}, status=400)
class StandardResultsSetPagination(PageNumberPagination):
page_size = 10
page_size_query_param = 'page_size'
max_page_size = 100
class PageViewSet(viewsets.ModelViewSet):
queryset = Page.objects.all().order_by('-created_at')
serializer_class = PageSerializer
pagination_class = StandardResultsSetPagination
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+57
View File
@@ -653,6 +653,10 @@ video {
top: 100%;
}
.z-0 {
z-index: 0;
}
.z-10 {
z-index: 10;
}
@@ -754,6 +758,10 @@ video {
display: block;
}
.inline {
display: inline;
}
.flex {
display: flex;
}
@@ -941,6 +949,12 @@ video {
row-gap: 2rem;
}
.-space-x-px > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(-1px * var(--tw-space-x-reverse));
margin-left: calc(-1px * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
@@ -1281,6 +1295,10 @@ video {
padding-right: 0.75rem;
}
.pt-5 {
padding-top: 1.25rem;
}
.text-left {
text-align: left;
}
@@ -1293,6 +1311,10 @@ video {
font-family: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
}
.font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.text-2xl {
font-size: 1.5rem;
line-height: 2rem;
@@ -1485,6 +1507,11 @@ video {
color: rgb(161 98 7 / var(--tw-text-opacity));
}
.text-red-500 {
--tw-text-opacity: 1;
color: rgb(239 68 68 / var(--tw-text-opacity));
}
.underline {
text-decoration-line: underline;
}
@@ -1493,6 +1520,12 @@ video {
text-decoration-line: line-through;
}
.shadow {
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.shadow-md {
--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
@@ -1672,6 +1705,16 @@ video {
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.hover\:text-gray-900:hover {
--tw-text-opacity: 1;
color: rgb(17 24 39 / var(--tw-text-opacity));
}
.hover\:text-green-800:hover {
--tw-text-opacity: 1;
color: rgb(22 101 52 / var(--tw-text-opacity));
}
.hover\:text-green-900:hover {
--tw-text-opacity: 1;
color: rgb(20 83 45 / var(--tw-text-opacity));
@@ -1687,6 +1730,11 @@ video {
color: rgb(49 46 129 / var(--tw-text-opacity));
}
.hover\:text-red-800:hover {
--tw-text-opacity: 1;
color: rgb(153 27 27 / var(--tw-text-opacity));
}
.hover\:text-red-900:hover {
--tw-text-opacity: 1;
color: rgb(127 29 29 / var(--tw-text-opacity));
@@ -1841,6 +1889,10 @@ video {
margin-bottom: calc(0px * var(--tw-space-y-reverse));
}
.sm\:rounded-md {
border-radius: 0.375rem;
}
.sm\:p-12 {
padding: 3rem;
}
@@ -1868,6 +1920,11 @@ video {
font-size: 1rem;
line-height: 1.5rem;
}
.sm\:text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
}
@media (min-width: 768px) {
+3
View File
@@ -11,3 +11,6 @@ gunicorn==22.0.0
whitenoise==5.3.0
django-simplemde==0.1.4
markdown==3.7
requests==2.32.3
beautifulsoup4==4.12.3
djangorestframework==3.15.2
+33 -3
View File
@@ -68,9 +68,39 @@
{% trans "Menu" %}
</button>
<div id="more-menu-dropdown" class="absolute right-0 mt-2 py-2 w-48 bg-white rounded-md shadow-xl z-20 hidden">
<a href="{% url 'search' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">{% trans "Advanced Search" %}</a>
<a href="{% url 'tools' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">{% trans "Tools" %}</a>
<a href="{% url 'help' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">{% trans "Help" %}</a>
<a href="{% url 'search' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
{% trans "Advanced Search" %}
</div>
</a>
<a href="{% url 'page-list' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9.5a2 2 0 00-2-2h-2"></path>
</svg>
{% trans "Pages" %}
</div>
</a>
<a href="{% url 'tools' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
{% trans "Tools" %}
</div>
</a>
<a href="{% url 'help' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{% trans "Help" %}
</div>
</a>
</div>
</div>
<!-- 语言选择器 -->
+14
View File
@@ -0,0 +1,14 @@
from django.conf import settings
from django.urls import resolve
from django.utils import translation
from django.middleware.locale import LocaleMiddleware
class CustomLocaleMiddleware(LocaleMiddleware):
def process_request(self, request):
url_path = request.path_info.lstrip('/')
# 检查是否是 API 路径
if url_path.startswith('api/'):
return None
return super().process_request(request)
+48 -1
View File
@@ -2,6 +2,7 @@ import os
from pathlib import Path
from django.utils.translation import gettext_lazy as _
import links.patches # 添加这一行在文件最上方
from django.urls import re_path
# 构建路径,如 BASE_DIR / 'subdir'
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -26,7 +27,7 @@ ROOT_URLCONF = 'url_manager.urls'
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware', # Make sure this is here
'url_manager.middleware.CustomLocaleMiddleware', # 替换原来的 LocaleMiddleware
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
@@ -145,3 +146,49 @@ LOGGING = {
},
},
}
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 10,
'UNAUTHENTICATED_USER': None, # 添加这行
}
LANGUAGE_URL_MAP = {
'en': 'en',
'zh-hans': 'zh',
}
# 添加这个配置来排除 API URLs 的语言重定向
LOCALE_PATHS = [
os.path.join(BASE_DIR, 'locale'),
]
PREFIX_DEFAULT_LANGUAGE = True
# 修改这里,使用简单的字符串模式而不是 re_path
LOCALE_INDEPENDENT_PATHS = [
'api/', # 只需要一个简单的字符串前缀
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'url_manager.middleware.CustomLocaleMiddleware', # 使用自定义中间件
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 10,
'UNAUTHENTICATED_USER': None,
}