mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
Add pages function
This commit is contained in:
Binary file not shown.
+10
-1
@@ -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}),
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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})
|
||||
|
||||
@@ -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']
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Vendored
+57
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
<!-- 语言选择器 -->
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user