mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
Binary file not shown.
@@ -2,6 +2,8 @@ from django import forms
|
||||
from .models import Link
|
||||
from simplemde.fields import SimpleMDEField
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.core.validators import URLValidator
|
||||
import re
|
||||
|
||||
class LinkForm(forms.ModelForm):
|
||||
text = SimpleMDEField()
|
||||
@@ -14,6 +16,28 @@ class LinkForm(forms.ModelForm):
|
||||
'description': forms.Textarea(attrs={'rows': 3}),
|
||||
}
|
||||
|
||||
def clean_original_url(self):
|
||||
url = self.cleaned_data.get('original_url')
|
||||
if not url:
|
||||
return url
|
||||
|
||||
# Extract template parameters
|
||||
template_params = re.findall(r'\{([^{}]*)\}', url)
|
||||
|
||||
# Temporarily replace template parameters with placeholder
|
||||
temp_url = url
|
||||
for param in template_params:
|
||||
param_full = '{' + param + '}'
|
||||
temp_url = temp_url.replace(param_full, 'template-param')
|
||||
|
||||
# Validate the URL with placeholders
|
||||
try:
|
||||
URLValidator()(temp_url)
|
||||
except forms.ValidationError:
|
||||
raise forms.ValidationError(_("Please enter a valid URL. Template parameters are allowed in the format {param_name,default=value}."))
|
||||
|
||||
return url
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
link_type = cleaned_data.get('link_type')
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.8 on 2024-11-02 11:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('links', '0007_link_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='link',
|
||||
name='original_url',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
+68
-2
@@ -2,6 +2,7 @@ from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.urls import reverse
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -11,8 +12,8 @@ class Link(models.Model):
|
||||
CUSTOM = 'CUSTOM', _('Custom')
|
||||
|
||||
alias = models.SlugField(max_length=100, unique=True)
|
||||
original_url = models.URLField(blank=True, null=True)
|
||||
text = models.TextField(blank=True, null=True) # 改为 TextField
|
||||
original_url = models.TextField(blank=True, null=True)
|
||||
text = models.TextField(blank=True, null=True)
|
||||
link_type = models.CharField(
|
||||
max_length=10,
|
||||
choices=LinkType.choices,
|
||||
@@ -23,6 +24,71 @@ class Link(models.Model):
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
|
||||
|
||||
def get_template_parameters(self):
|
||||
"""Extract template parameters and their default values from original_url"""
|
||||
pattern = r'\{([^{}]*)\}'
|
||||
matches = re.finditer(pattern, self.original_url)
|
||||
params = {}
|
||||
|
||||
for match in matches:
|
||||
param_str = match.group(1)
|
||||
if ',' in param_str:
|
||||
param_name, default_value = param_str.split(',', 1)
|
||||
default_value = default_value.strip()
|
||||
if default_value.startswith('default='):
|
||||
default_value = default_value[8:].strip('"\'')
|
||||
params[param_name.strip()] = default_value
|
||||
else:
|
||||
params[param_str.strip()] = None
|
||||
|
||||
return params
|
||||
|
||||
def get_processed_url(self, **kwargs):
|
||||
"""Process template URL with provided parameters"""
|
||||
if not self.original_url:
|
||||
return None
|
||||
|
||||
processed_url = self.original_url
|
||||
pattern = r'\{([^{}]*)\}'
|
||||
matches = re.finditer(pattern, self.original_url)
|
||||
|
||||
# 收集所有参数及其默认值
|
||||
params = {}
|
||||
for match in matches:
|
||||
param_str = match.group(1)
|
||||
param_full = '{' + param_str + '}'
|
||||
|
||||
# 处理参数字符串
|
||||
param_parts = [p.strip() for p in param_str.split(',', 1)]
|
||||
param_name = param_parts[0].strip()
|
||||
default_value = None
|
||||
|
||||
if len(param_parts) > 1:
|
||||
# 处理默认值部分
|
||||
default_part = param_parts[1].strip()
|
||||
if default_part.startswith('default='):
|
||||
default_value = default_part[8:].strip() # 移除 'default=' 前缀
|
||||
# 移除引号(如果存在)
|
||||
if (default_value.startswith('"') and default_value.endswith('"')) or \
|
||||
(default_value.startswith("'") and default_value.endswith("'")):
|
||||
default_value = default_value[1:-1]
|
||||
|
||||
params[param_name] = {
|
||||
'default': default_value,
|
||||
'full_match': param_full
|
||||
}
|
||||
|
||||
# 替换所有参数
|
||||
for param_name, param_info in params.items():
|
||||
value = kwargs.get(param_name, param_info['default'])
|
||||
if value is None:
|
||||
raise ValueError(f"No value provided for parameter {param_name}")
|
||||
|
||||
# 使用完整的匹配模式进行替换
|
||||
processed_url = processed_url.replace(param_info['full_match'], str(value))
|
||||
|
||||
return processed_url
|
||||
|
||||
def __str__(self):
|
||||
return self.alias
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
from django import urls
|
||||
|
||||
sys.modules['django.core.urlresolvers'] = urls
|
||||
@@ -0,0 +1,141 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% load markdown_extras %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss-typography/0.4.0/typography.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.prose {
|
||||
color: #374151;
|
||||
max-width: none !important;
|
||||
}
|
||||
.prose p {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.prose strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose ul {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
list-style-type: disc;
|
||||
padding-left: 1.625em;
|
||||
}
|
||||
.prose ol {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.625em;
|
||||
}
|
||||
.prose h1 {
|
||||
font-size: 2.25em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8888889em;
|
||||
line-height: 1.1111111;
|
||||
}
|
||||
.prose h2 {
|
||||
font-size: 1.5em;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
line-height: 1.3333333;
|
||||
}
|
||||
.prose h3 {
|
||||
font-size: 1.25em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.6em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.prose img {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose code {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
background-color: #f3f4f6;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.prose pre {
|
||||
color: #e5e7eb;
|
||||
background-color: #1f2937;
|
||||
overflow-x: auto;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
border-radius: 0.375rem;
|
||||
padding-top: 0.8571429em;
|
||||
padding-right: 1.1428571em;
|
||||
padding-bottom: 0.8571429em;
|
||||
padding-left: 1.1428571em;
|
||||
}
|
||||
.prose pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
</style>
|
||||
{% 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 %}
|
||||
# Frequently Asked Questions (FAQ)
|
||||
|
||||
## Link Types
|
||||
|
||||
GoLinks supports three types of links: Normal Links, Template Links, and Custom Pages. Each type serves different purposes and has its own use cases.
|
||||
|
||||
### 1. Normal Links
|
||||
|
||||
Normal links are the simplest type - they provide direct redirection to a target URL.
|
||||
|
||||
**Example:**
|
||||
- Original URL: `https://github.com/microsoft/vscode`
|
||||
- Alias: `vscode`
|
||||
- Access: `http://go/vscode` → Redirects to VS Code GitHub repository
|
||||
|
||||
**Use Cases:**
|
||||
- Quick access to frequently visited websites
|
||||
- Shortening long URLs
|
||||
- Creating memorable aliases for complex URLs
|
||||
|
||||
### 2. Template Links
|
||||
|
||||
Template links allow you to create dynamic URLs that can generate the final URL based on parameters. This is particularly useful for search links, API calls, or any URL that needs dynamic parameters.
|
||||
|
||||
**Basic Syntax:**
|
||||
Use the syntax `{parameter_name, default=default_value}` to define variables in your URL: `https://google.com?q={query, default=hello}`, and set the alias to `google`.
|
||||
|
||||
**Examples:**
|
||||
- Use the syntax `{query, default=default_value}` to define variables in your URL: `https://google.com?q={query, default=hello}`, and set the alias to `google`.
|
||||
|
||||
Then you can access the link by `http://go/google/<query>`, and the URL will be `https://google.com?q=<query>`.
|
||||
|
||||
When you access the url with `http://go/google/`, the URL will be `https://google.com?q=hello`, which will leverage the `default` value.
|
||||
|
||||
### 3. Custom Pages
|
||||
|
||||
Custom pages allow you to create markdown-based web pages that can serve as documentation, link collections, or any other content you need.
|
||||
|
||||
**Key Features:**
|
||||
- Write content in Markdown format
|
||||
- Support for headings, lists, code blocks, and links
|
||||
- Can include multiple sections and navigation
|
||||
- Perfect for creating documentation or resource collections
|
||||
|
||||
**Example Use Cases:**
|
||||
Check http://go.junv.cc/custom/today/
|
||||
{% endfilter %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -8,7 +8,11 @@
|
||||
<div class="px-4 py-5 sm:px-6 bg-gray-50 border-b border-gray-200 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
|
||||
{% trans "Link Details" %}
|
||||
{% if link.link_type == 'LINK' %}
|
||||
{% if '{' in link.original_url and '}' in link.original_url %}
|
||||
<span class="ml-2 bg-purple-100 text-purple-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full flex items-center">
|
||||
<i class="fas fa-magic mr-1"></i>{% trans "Template" %}
|
||||
</span>
|
||||
{% elif link.link_type == 'LINK' %}
|
||||
<span class="ml-2 bg-blue-100 text-blue-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full flex items-center">
|
||||
<i class="fas fa-link mr-1"></i>{% trans "Link" %}
|
||||
</span>
|
||||
@@ -19,7 +23,7 @@
|
||||
{% endif %}
|
||||
</h1>
|
||||
<div class="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2">
|
||||
<a href="{{ link.original_url }}" target="_blank" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded text-sm sm:text-base inline-flex items-center justify-center">
|
||||
<a href="{% url 'redirect_to_original' link.alias %}" target="_blank" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded text-sm sm:text-base inline-flex items-center justify-center">
|
||||
<svg class="w-4 h-4 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 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||
</svg>
|
||||
|
||||
@@ -45,6 +45,14 @@
|
||||
{% trans "Custom type allows you to create a link with custom HTML content. Use this when you want to display a message or custom content instead of redirecting." %}
|
||||
</p>
|
||||
</div>
|
||||
<div id="template-description" class="bg-purple-50 border-l-4 border-purple-400 p-4 mb-4 {% if is_custom %}hidden{% endif %}">
|
||||
<p class="text-sm text-purple-700">
|
||||
{% trans "Template type allows you to create dynamic URLs with parameters. Use {query, default=value} syntax in the URL." %}
|
||||
</p>
|
||||
<p class="text-sm text-purple-700 mt-2">
|
||||
{% trans "Example: https://google.com/search?q={query, default=hello}" %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form method="post" class="space-y-6">
|
||||
|
||||
@@ -105,16 +105,25 @@
|
||||
</td>
|
||||
<td class="px-3 py-2 sm:px-6 sm:py-4 whitespace-nowrap hidden sm:table-cell">
|
||||
<span class="inline-flex items-center">
|
||||
{% if link.link_type == 'LINK' %}
|
||||
{% if '{' in link.original_url and '}' in link.original_url %}
|
||||
<i class="fas fa-magic text-purple-500"></i>
|
||||
<span class="ml-2 text-sm text-purple-600">Template</span>
|
||||
{% elif link.link_type == 'LINK' %}
|
||||
<i class="fas fa-link text-blue-500"></i>
|
||||
<span class="ml-2 text-sm text-blue-600">Link</span>
|
||||
{% else %}
|
||||
<i class="fas fa-code text-green-500"></i>
|
||||
<span class="ml-2 text-sm text-green-600">Custom</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 sm:px-6 sm:py-4 whitespace-nowrap hidden md:table-cell">
|
||||
<div class="truncate max-w-xs" title="{{ link.original_url }}">
|
||||
<a href="{{ link.original_url }}" target="_blank" class="text-blue-600 hover:text-blue-900">{{ link.original_url }}</a>
|
||||
<a href="{% url 'redirect_to_original' link.alias %}"
|
||||
class="text-blue-600 hover:text-blue-800 break-all"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
{{ link.original_url }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2 sm:px-6 sm:py-4 whitespace-nowrap hidden sm:table-cell">{{ link.click_count }}</td>
|
||||
|
||||
@@ -3,93 +3,143 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-6">{% trans "Advanced Search" %}</h1>
|
||||
<!-- Header Section -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">{% trans "Advanced Search" %}</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">{% trans "Search through all links by alias or URL" %}</p>
|
||||
</div>
|
||||
|
||||
<form action="{% url 'search' %}" method="get" class="mb-8">
|
||||
<div class="flex flex-col sm:flex-row space-y-4 sm:space-y-0 sm:space-x-4">
|
||||
<div class="flex-grow">
|
||||
<input type="text" name="q" value="{{ query }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="{% trans 'Enter search query' %}">
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full sm:w-auto px-6 py-2 bg-blue-600 text-white font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
{% trans "Search" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if query %}
|
||||
<h2 class="text-2xl font-semibold text-gray-800 mb-4">{% trans "Search results for" %}: {{ query }}</h2>
|
||||
|
||||
{% if links %}
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul class="divide-y divide-gray-200">
|
||||
{% for link in links %}
|
||||
<li>
|
||||
<div class="px-4 py-4 sm:px-6 hover:bg-gray-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="{{ link.original_url }}" class="text-lg font-medium text-blue-600 hover:text-blue-800 truncate">{{ link.alias }}</a>
|
||||
<div class="ml-2 flex-shrink-0 flex">
|
||||
<a href="{% url 'link_detail' link.pk %}" class="mr-2 font-medium text-blue-600 hover:text-blue-800">{% trans "Details" %}</a>
|
||||
<a href="{% url 'link_update' link.pk %}" class="font-medium text-indigo-600 hover:text-indigo-800">{% trans "Edit" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 sm:flex sm:justify-between">
|
||||
<div class="sm:flex">
|
||||
<p class="flex items-center text-sm text-gray-500">
|
||||
<svg class="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{ link.original_url }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center text-sm text-gray-500 sm:mt-0">
|
||||
<svg class="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{ link.created_at|date:"Y-m-d H:i" }}
|
||||
</div>
|
||||
<!-- Search Form Section -->
|
||||
<div class="bg-white shadow-sm rounded-lg overflow-hidden mb-8">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<form action="{% url 'search' %}" method="get" class="space-y-4">
|
||||
<div class="max-w-7xl">
|
||||
<label for="search-input" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{% trans "Search Query" %}
|
||||
</label>
|
||||
<div class="mt-1 flex rounded-md shadow-sm">
|
||||
<div class="relative flex-grow">
|
||||
<input type="text"
|
||||
name="q"
|
||||
id="search-input"
|
||||
value="{{ query }}"
|
||||
class="block w-full rounded-l-md border-gray-300 focus:border-blue-500 focus:ring-blue-500 sm:text-base py-3"
|
||||
placeholder="{% trans 'Enter keywords to search...' %}">
|
||||
<div class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<button type="submit"
|
||||
class="relative -ml-px inline-flex items-center space-x-2 rounded-r-md border border-gray-300 bg-blue-600 px-6 py-3 text-base font-medium text-white hover:bg-blue-700 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 transition-colors duration-200">
|
||||
{% trans "Search" %}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500">{% trans "Try searching by alias, URL, or any related keywords" %}</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
{% if query %}
|
||||
<div class="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">
|
||||
{% trans "Search results for" %}: <span class="font-semibold">{{ query }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{% if is_paginated %}
|
||||
<nav class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6 mt-4" aria-label="Pagination">
|
||||
<div class="hidden sm:block">
|
||||
<p class="text-sm text-gray-700">
|
||||
{% trans "Showing" %}
|
||||
<span class="font-medium">{{ page_obj.start_index }}</span>
|
||||
{% trans "to" %}
|
||||
<span class="font-medium">{{ page_obj.end_index }}</span>
|
||||
{% trans "of" %}
|
||||
<span class="font-medium">{{ paginator.count }}</span>
|
||||
{% trans "results" %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-1 flex justify-between sm:justify-end">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?q={{ query }}&page={{ page_obj.previous_page_number }}" class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
{% trans "Previous" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?q={{ query }}&page={{ page_obj.next_page_number }}" class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
{% trans "Next" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% if links %}
|
||||
<ul class="divide-y divide-gray-200">
|
||||
{% for link in links %}
|
||||
<li class="hover:bg-gray-50 transition-colors duration-150">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-medium text-blue-600 truncate">
|
||||
<a href="{{ link.original_url }}" target="_blank" class="hover:text-blue-800">
|
||||
{{ link.alias }}
|
||||
</a>
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-600 truncate">{{ link.original_url }}</p>
|
||||
</div>
|
||||
<div class="ml-4 flex-shrink-0 flex space-x-4">
|
||||
<a href="{% url 'link_detail' link.pk %}"
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-800">
|
||||
{% trans "Details" %}
|
||||
</a>
|
||||
<span class="text-gray-300">|</span>
|
||||
<a href="{% url 'link_update' link.pk %}"
|
||||
class="text-sm font-medium text-indigo-600 hover:text-indigo-800">
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 sm:flex sm:justify-between">
|
||||
<div class="flex items-center text-sm text-gray-500">
|
||||
<svg class="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
{{ link.click_count }} {% trans "clicks" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% else %}
|
||||
<p class="text-gray-600">{% trans "No results found." %}</p>
|
||||
{% endif %}
|
||||
{% if is_paginated %}
|
||||
<nav class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6" aria-label="Pagination">
|
||||
<div class="hidden sm:block">
|
||||
<p class="text-sm text-gray-700">
|
||||
{% trans "Showing" %}
|
||||
<span class="font-medium">{{ page_obj.start_index }}</span>
|
||||
{% trans "to" %}
|
||||
<span class="font-medium">{{ page_obj.end_index }}</span>
|
||||
{% trans "of" %}
|
||||
<span class="font-medium">{{ paginator.count }}</span>
|
||||
{% trans "results" %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-1 flex justify-between sm:justify-end space-x-3">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?q={{ query }}&page={{ page_obj.previous_page_number }}"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
{% trans "Previous" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?q={{ query }}&page={{ page_obj.next_page_number }}"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
{% trans "Next" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="px-4 py-5 sm:p-6 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">{% trans "No results found" %}</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">{% trans "Try adjusting your search terms." %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-600">{% trans "Enter a search query to find links." %}</p>
|
||||
<div class="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-12 sm:p-12 text-center">
|
||||
<svg class="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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" />
|
||||
</svg>
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900">{% trans "Start searching" %}</h3>
|
||||
<p class="mt-2 text-base text-gray-500 max-w-md mx-auto">{% trans "Enter a search query to find links. You can search by alias, URL, or any related keywords." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,28 +7,61 @@
|
||||
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<h1 class="text-2xl font-bold text-gray-800">{% trans "Tools" %}</h1>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">{% trans "Export Aliases" %}</h2>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" name="export" class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded">
|
||||
{% trans "Export as JSON" %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-8 mb-4">{% trans "Import Aliases" %}</h2>
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-700">{% trans "Import aliases from a JSON file. The file should contain a list of objects with the following fields:" %}</p>
|
||||
<ul class="list-disc list-inside mt-2 ml-4 text-gray-600">
|
||||
<li>{% trans "alias (required): The short alias for the URL" %}</li>
|
||||
<li>{% trans "original_url (required): The original URL" %}</li>
|
||||
<li>{% trans "created_at (optional): Creation timestamp (ISO format)" %}</li>
|
||||
<li>{% trans "updated_at (optional): Last update timestamp (ISO format)" %}</li>
|
||||
</ul>
|
||||
<div class="p-6 space-y-8">
|
||||
<!-- Export Links Section -->
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden">
|
||||
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold">{% trans "Export Aliases" %}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form method="post" class="mb-4">
|
||||
{% csrf_token %}
|
||||
<button type="submit" name="export" class="bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium py-2 px-4 rounded inline-flex items-center transition duration-150 ease-in-out">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
{% trans "Export as JSON" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-100 p-4 rounded-lg mb-4">
|
||||
<h3 class="text-lg font-semibold mb-2">{% trans "Example JSON file content:" %}</h3>
|
||||
<pre class="bg-white p-2 rounded text-sm overflow-x-auto">
|
||||
|
||||
<!-- Database Export Section -->
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden">
|
||||
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold">{% trans "Database Export" %}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<p class="text-gray-600 text-sm mb-4">{% trans "Export the entire database as a compressed file." %}</p>
|
||||
<a href="{% url 'export_database' %}"
|
||||
class="bg-purple-500 hover:bg-purple-600 text-white text-sm font-medium py-2 px-4 rounded inline-flex items-center transition duration-150 ease-in-out">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
{% trans "Export Database" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Links Section -->
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden">
|
||||
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold">{% trans "Import Aliases" %}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<p class="text-gray-600 text-sm mb-4">{% trans "Import aliases from a JSON file. The file should contain a list of objects with the following fields:" %}</p>
|
||||
<ul class="list-disc list-inside text-sm text-gray-600 space-y-1 ml-4">
|
||||
<li>{% trans "alias (required): The short alias for the URL" %}</li>
|
||||
<li>{% trans "original_url (required): The original URL" %}</li>
|
||||
<li>{% trans "created_at (optional): Creation timestamp (ISO format)" %}</li>
|
||||
<li>{% trans "updated_at (optional): Last update timestamp (ISO format)" %}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">{% trans "Example JSON file content:" %}</h3>
|
||||
<pre class="bg-white p-3 rounded text-xs overflow-x-auto">
|
||||
[
|
||||
{
|
||||
"alias": "example",
|
||||
@@ -41,18 +74,25 @@
|
||||
"original_url": "https://www.google.com"
|
||||
}
|
||||
]
|
||||
</pre>
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="mb-4">
|
||||
<input type="file" name="json_file" accept=".json" required class="border rounded p-2">
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="mb-4">
|
||||
<input type="file" name="json_file" accept=".json" required
|
||||
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
|
||||
</div>
|
||||
<button type="submit" name="import"
|
||||
class="bg-green-500 hover:bg-green-600 text-white text-sm font-medium py-2 px-4 rounded inline-flex items-center transition duration-150 ease-in-out">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
|
||||
</svg>
|
||||
{% trans "Import from JSON" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<button type="submit" name="import" class="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded">
|
||||
{% trans "Import from JSON" %}
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ export_url }}" class="btn btn-primary">{% trans "Export Links" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from django import template
|
||||
import markdown as md
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.filter
|
||||
def markdown(value):
|
||||
return md.markdown(value, extensions=['fenced_code', 'tables'])
|
||||
+8
-4
@@ -8,13 +8,17 @@ urlpatterns = [
|
||||
path('delete/<int:pk>/', views.LinkDeleteView.as_view(), name='link_delete'),
|
||||
path('delete-selected/', views.delete_selected, name='delete_selected'),
|
||||
path('detail/<int:pk>/', views.LinkDetailView.as_view(), name='link_detail'),
|
||||
path('search/', views.SearchView.as_view(), name='search'),
|
||||
path('ui/search/', views.SearchView.as_view(), name='search'),
|
||||
path('link/<int:pk>/', views.LinkDetailView.as_view(), name='link_detail'),
|
||||
path('link/<int:pk>/edit/', views.LinkUpdateView.as_view(), name='link_update'),
|
||||
path('tools/', views.ToolsView.as_view(), name='tools'),
|
||||
path('<str:alias>/', views.redirect_to_original, name='redirect_to_original'),
|
||||
path('ui/tools/', views.ToolsView.as_view(), name='tools'),
|
||||
path('tools/export-database/', views.export_database, name='export_database'),
|
||||
path('ui/help/', views.HelpView.as_view(), name='help'),
|
||||
path('export/', views.export_links, name='export_links'),
|
||||
|
||||
# New route for link detail view by alias
|
||||
# Aliases
|
||||
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'),
|
||||
|
||||
]
|
||||
|
||||
+71
-4
@@ -18,6 +18,15 @@ import random
|
||||
from django.utils.text import slugify
|
||||
import markdown
|
||||
import logging
|
||||
import re
|
||||
import os
|
||||
import tarfile
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from django.conf import settings
|
||||
from django.http import FileResponse
|
||||
from wsgiref.util import FileWrapper
|
||||
from django.contrib.auth.decorators import user_passes_test
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -178,16 +187,40 @@ def delete_selected(request):
|
||||
Link.objects.filter(id__in=selected_ids).delete()
|
||||
return redirect('link_list')
|
||||
|
||||
def redirect_to_original(request, alias):
|
||||
# 将别名转换为小写并移除特殊字符
|
||||
def redirect_to_original(request, alias, param=None):
|
||||
processed_alias = ''.join(e for e in alias.lower() if e.isalnum())
|
||||
|
||||
try:
|
||||
link = Link.objects.get(alias=processed_alias)
|
||||
link.click_count = F('click_count') + 1
|
||||
link.save()
|
||||
ClickLog.objects.create(link=link) # 记录点击
|
||||
return redirect(link.original_url)
|
||||
ClickLog.objects.create(link=link)
|
||||
|
||||
try:
|
||||
# 如果是模板 URL 并且提供了参数
|
||||
if param:
|
||||
# 从 URL 中提取参数名
|
||||
pattern = r'\{([^{}]*)\}'
|
||||
matches = re.finditer(pattern, link.original_url)
|
||||
for match in matches:
|
||||
param_str = match.group(1)
|
||||
if ',' in param_str:
|
||||
param_name = param_str.split(',')[0].strip()
|
||||
else:
|
||||
param_name = param_str.strip()
|
||||
# 使用提供的参数值
|
||||
url = link.get_processed_url(**{param_name: param})
|
||||
return redirect(url)
|
||||
|
||||
# 如果没有提供参数,使用默认值
|
||||
url = link.get_processed_url()
|
||||
if url is None:
|
||||
url = link.original_url
|
||||
return redirect(url)
|
||||
except ValueError as e:
|
||||
messages.error(request, str(e))
|
||||
return redirect('link_list')
|
||||
|
||||
except Link.DoesNotExist:
|
||||
messages.warning(request, _("The alias '{}' doesn't exist. Do you want to create a new one?").format(processed_alias))
|
||||
return redirect(reverse('link_create') + f'?alias={processed_alias}')
|
||||
@@ -365,3 +398,37 @@ class CustomLinkView(DetailView):
|
||||
# Render markdown text as HTML
|
||||
context['rendered_text'] = markdown.markdown(self.object.text)
|
||||
return context
|
||||
|
||||
def export_database(request):
|
||||
"""Export the entire SQLite database as a tarball"""
|
||||
try:
|
||||
# 获取数据库文件路径
|
||||
db_path = settings.DATABASES['default']['NAME']
|
||||
|
||||
# 创建临时目录
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# 创建带时间戳的文件名
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
tar_filename = f'database_backup_{timestamp}.tar.gz'
|
||||
tar_filepath = os.path.join(temp_dir, tar_filename)
|
||||
|
||||
# 创建tar文件
|
||||
with tarfile.open(tar_filepath, 'w:gz') as tar:
|
||||
# 添加数据库文件到tar
|
||||
tar.add(db_path, arcname=os.path.basename(db_path))
|
||||
|
||||
# 打开文件准备下载
|
||||
wrapper = FileWrapper(open(tar_filepath, 'rb'))
|
||||
response = FileResponse(wrapper, content_type='application/x-gzip')
|
||||
response['Content-Disposition'] = f'attachment; filename="{tar_filename}"'
|
||||
response['Content-Length'] = os.path.getsize(tar_filepath)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database export failed: {str(e)}")
|
||||
messages.error(request, _("Failed to export database: {}").format(str(e)))
|
||||
return redirect('tools')
|
||||
|
||||
class HelpView(TemplateView):
|
||||
template_name = 'links/help.html'
|
||||
|
||||
Binary file not shown.
@@ -1856,7 +1856,7 @@ msgstr "无效的JSON格式。预期是对象列表。"
|
||||
#: links/views.py:282
|
||||
msgid ""
|
||||
"Invalid item in JSON. Each item must be an object with an 'alias' field."
|
||||
msgstr "JSON中存在无效项。每个项目必须是包含'alias'字段的对象。"
|
||||
msgstr "JSON中存在无效项。每个项必须是包含'alias'字段的对象。"
|
||||
|
||||
#: links/views.py:295
|
||||
#, fuzzy, python-brace-format
|
||||
@@ -1910,3 +1910,21 @@ msgstr "简体中文"
|
||||
|
||||
msgid "Description"
|
||||
msgstr "描述"
|
||||
|
||||
msgid "Please enter a valid URL. Template parameters are allowed in the format {param_name,default=value}."
|
||||
msgstr "请输入有效的URL。允许使用模板参数,格式为 {参数名,default=默认值}。"
|
||||
|
||||
msgid "Database Export"
|
||||
msgstr "数据库导出"
|
||||
|
||||
msgid "Export the entire database as a compressed file. This feature is only available to administrators."
|
||||
msgstr "将整个数据库导出为压缩文件。此功能仅管理员可用。"
|
||||
|
||||
msgid "Export Database"
|
||||
msgstr "导出数据库"
|
||||
|
||||
msgid "Only administrators can export the database."
|
||||
msgstr "只有管理员可以导出数据库。"
|
||||
|
||||
msgid "Failed to export database: {}"
|
||||
msgstr "导出数据库失败:{}"
|
||||
|
||||
Vendored
+186
-22
@@ -670,6 +670,14 @@ video {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.-ml-px {
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
@@ -694,10 +702,6 @@ video {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.ml-3 {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.ml-4 {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
@@ -774,6 +778,14 @@ video {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.h-12 {
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.h-16 {
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.h-4 {
|
||||
height: 1rem;
|
||||
}
|
||||
@@ -786,6 +798,14 @@ video {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.w-12 {
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
.w-16 {
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
.w-4 {
|
||||
width: 1rem;
|
||||
}
|
||||
@@ -802,6 +822,10 @@ video {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.min-w-0 {
|
||||
min-width: 0px;
|
||||
}
|
||||
|
||||
.min-w-full {
|
||||
min-width: 100%;
|
||||
}
|
||||
@@ -822,6 +846,10 @@ video {
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
.max-w-md {
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.max-w-none {
|
||||
max-width: none;
|
||||
}
|
||||
@@ -913,6 +941,12 @@ video {
|
||||
row-gap: 2rem;
|
||||
}
|
||||
|
||||
.space-x-2 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
|
||||
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
|
||||
}
|
||||
|
||||
.space-x-3 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(0.75rem * var(--tw-space-x-reverse));
|
||||
@@ -949,6 +983,12 @@ video {
|
||||
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
|
||||
.space-y-8 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(2rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(2rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
|
||||
.divide-y > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-divide-y-reverse: 0;
|
||||
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
|
||||
@@ -978,6 +1018,10 @@ video {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.break-all {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
@@ -994,6 +1038,16 @@ video {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.rounded-l-md {
|
||||
border-top-left-radius: 0.375rem;
|
||||
border-bottom-left-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.rounded-r-md {
|
||||
border-top-right-radius: 0.375rem;
|
||||
border-bottom-right-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
@@ -1039,6 +1093,11 @@ video {
|
||||
border-color: rgb(74 222 128 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-purple-400 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(192 132 252 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-transparent {
|
||||
border-color: transparent;
|
||||
}
|
||||
@@ -1098,6 +1157,21 @@ video {
|
||||
background-color: rgb(34 197 94 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-purple-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(243 232 255 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-purple-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(250 245 255 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-purple-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(168 85 247 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-red-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
|
||||
@@ -1126,6 +1200,10 @@ video {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.p-3 {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
@@ -1169,6 +1247,11 @@ video {
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.py-12 {
|
||||
padding-top: 3rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.py-2 {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
@@ -1225,6 +1308,11 @@ video {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.text-base {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
@@ -1297,6 +1385,11 @@ video {
|
||||
color: rgb(30 64 175 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-gray-300 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(209 213 219 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-gray-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(156 163 175 / var(--tw-text-opacity));
|
||||
@@ -1352,6 +1445,26 @@ video {
|
||||
color: rgb(79 70 229 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-purple-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(168 85 247 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-purple-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(147 51 234 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-purple-700 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(126 34 206 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-purple-800 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(107 33 168 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(220 38 38 / var(--tw-text-opacity));
|
||||
@@ -1380,12 +1493,6 @@ 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);
|
||||
@@ -1421,14 +1528,65 @@ video {
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.transition-colors {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.duration-150 {
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.duration-200 {
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
.ease-in-out {
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.file\:mr-4::file-selector-button {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.file\:rounded-md::file-selector-button {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.file\:border-0::file-selector-button {
|
||||
border-width: 0px;
|
||||
}
|
||||
|
||||
.file\:bg-blue-50::file-selector-button {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(239 246 255 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.file\:px-4::file-selector-button {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.file\:py-2::file-selector-button {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.file\:text-sm::file-selector-button {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.file\:font-medium::file-selector-button {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file\:text-blue-700::file-selector-button {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(29 78 216 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:border-blue-700:hover {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(29 78 216 / var(--tw-border-opacity));
|
||||
@@ -1474,6 +1632,11 @@ video {
|
||||
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-purple-600:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(147 51 234 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-red-600:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
|
||||
@@ -1533,6 +1696,11 @@ video {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
.hover\:file\:bg-blue-100::file-selector-button:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(219 234 254 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.focus\:border-blue-300:focus {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(147 197 253 / var(--tw-border-opacity));
|
||||
@@ -1563,6 +1731,12 @@ video {
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||
}
|
||||
|
||||
.focus\:ring-1:focus {
|
||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||
}
|
||||
|
||||
.focus\:ring-2:focus {
|
||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
@@ -1611,10 +1785,6 @@ video {
|
||||
grid-column: span 2 / span 2;
|
||||
}
|
||||
|
||||
.sm\:mt-0 {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.sm\:block {
|
||||
display: block;
|
||||
}
|
||||
@@ -1665,20 +1835,14 @@ video {
|
||||
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
|
||||
}
|
||||
|
||||
.sm\:space-x-4 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(1rem * var(--tw-space-x-reverse));
|
||||
margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
|
||||
}
|
||||
|
||||
.sm\:space-y-0 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(0px * var(--tw-space-y-reverse));
|
||||
}
|
||||
|
||||
.sm\:rounded-md {
|
||||
border-radius: 0.375rem;
|
||||
.sm\:p-12 {
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.sm\:p-6 {
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 语言选择器 -->
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import links.patches # 添加这一行在文件最上方
|
||||
|
||||
# 构建路径,如 BASE_DIR / 'subdir'
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
@@ -17,6 +18,7 @@ INSTALLED_APPS = [
|
||||
'tailwind',
|
||||
'new_theme',
|
||||
'simplemde',
|
||||
'markdown', # 只需要基本的markdown包
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'url_manager.urls'
|
||||
|
||||
Reference in New Issue
Block a user