Phase 2: T-201 至 T-206 前台 MVP 全部实现(11 templates + CSS + 31 tests)
This commit is contained in:
@@ -10,9 +10,9 @@ Skelet 是一个介绍、分类、评测开源项目骨架的网站。目标用
|
|||||||
|
|
||||||
## 当前阶段
|
## 当前阶段
|
||||||
|
|
||||||
Phase 1(内容模型)完成。T-101 / T-102 / T-103 / T-104 已实现并验收。
|
Phase 2(前台 MVP)完成。T-201 至 T-206 已实现(基础页面框架、首页、场景页、项目列表、筛选搜索、详情页、文章列表与详情)。
|
||||||
|
|
||||||
下一步:进入 Phase 2,从 [`docs/06-tasks.md`](docs/06-tasks.md) 领取 `T-201`:实现基础页面框架。
|
下一步:进入 Phase 3,从 [`docs/06-tasks.md`](docs/06-tasks.md) 领取 `T-301`:补 SEO 基础。
|
||||||
|
|
||||||
## 开发环境
|
## 开发环境
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
from django.core.paginator import Paginator, EmptyPage
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
from modelcluster.fields import ParentalManyToManyField
|
from modelcluster.fields import ParentalManyToManyField
|
||||||
@@ -25,6 +27,18 @@ class ScenarioPage(Page):
|
|||||||
|
|
||||||
content_panels = Page.content_panels
|
content_panels = Page.content_panels
|
||||||
|
|
||||||
|
template = "core/scenario_page.html"
|
||||||
|
|
||||||
|
def get_context(self, request, *args, **kwargs):
|
||||||
|
context = super().get_context(request, *args, **kwargs)
|
||||||
|
context["projects"] = (
|
||||||
|
SkeletonProjectPage.objects.live()
|
||||||
|
.filter(scenarios=self)
|
||||||
|
.prefetch_related("languages", "scenarios")
|
||||||
|
.order_by("-first_published_at")
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Scenario"
|
verbose_name = "Scenario"
|
||||||
|
|
||||||
@@ -112,11 +126,65 @@ class ProjectIndexPage(Page):
|
|||||||
|
|
||||||
content_panels = Page.content_panels
|
content_panels = Page.content_panels
|
||||||
|
|
||||||
|
template = "core/project_index_page.html"
|
||||||
|
|
||||||
|
def get_context(self, request, *args, **kwargs):
|
||||||
|
context = super().get_context(request, *args, **kwargs)
|
||||||
|
projects = (
|
||||||
|
SkeletonProjectPage.objects.live()
|
||||||
|
.prefetch_related("languages", "scenarios")
|
||||||
|
.order_by("-first_published_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
language = request.GET.get("language", "")
|
||||||
|
framework = request.GET.get("framework", "")
|
||||||
|
database = request.GET.get("database", "")
|
||||||
|
min_score = request.GET.get("min_score", "")
|
||||||
|
q = request.GET.get("q", "")
|
||||||
|
|
||||||
|
if language:
|
||||||
|
projects = projects.filter(languages__slug=language)
|
||||||
|
if framework:
|
||||||
|
projects = projects.filter(frameworks__slug=framework)
|
||||||
|
if database:
|
||||||
|
projects = projects.filter(databases__slug=database)
|
||||||
|
try:
|
||||||
|
score_val = int(min_score)
|
||||||
|
if 0 <= score_val <= 30:
|
||||||
|
projects = [
|
||||||
|
p for p in projects if p.total_score >= score_val
|
||||||
|
]
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if q:
|
||||||
|
projects = projects.filter(
|
||||||
|
Q(title__icontains=q) | Q(summary__icontains=q)
|
||||||
|
)
|
||||||
|
|
||||||
|
paginator = Paginator(projects, 20)
|
||||||
|
page = request.GET.get("page", "1")
|
||||||
|
try:
|
||||||
|
context["projects"] = paginator.page(int(page))
|
||||||
|
except (ValueError, EmptyPage):
|
||||||
|
context["projects"] = paginator.page(1)
|
||||||
|
|
||||||
|
context["current_language"] = language
|
||||||
|
context["current_framework"] = framework
|
||||||
|
context["current_database"] = database
|
||||||
|
context["current_min_score"] = min_score
|
||||||
|
context["current_q"] = q
|
||||||
|
context["languages"] = Language.objects.all()
|
||||||
|
context["frameworks"] = Framework.objects.all()
|
||||||
|
context["databases"] = DatabaseOption.objects.all()
|
||||||
|
return context
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Project Index"
|
verbose_name = "Project Index"
|
||||||
|
|
||||||
|
|
||||||
class SkeletonProjectPage(Page):
|
class SkeletonProjectPage(Page):
|
||||||
|
template = "core/skeleton_project_page.html"
|
||||||
|
|
||||||
MATURITY_CHOICES = [
|
MATURITY_CHOICES = [
|
||||||
("experimental", "Experimental"),
|
("experimental", "Experimental"),
|
||||||
("stable", "Stable"),
|
("stable", "Stable"),
|
||||||
@@ -255,6 +323,7 @@ class SkeletonProjectPage(Page):
|
|||||||
|
|
||||||
|
|
||||||
class ArticleIndexPage(Page):
|
class ArticleIndexPage(Page):
|
||||||
|
template = "core/article_index_page.html"
|
||||||
parent_page_types = ["home.HomePage"]
|
parent_page_types = ["home.HomePage"]
|
||||||
subpage_types = ["core.ArticlePage"]
|
subpage_types = ["core.ArticlePage"]
|
||||||
max_count = 1
|
max_count = 1
|
||||||
@@ -266,6 +335,7 @@ class ArticleIndexPage(Page):
|
|||||||
|
|
||||||
|
|
||||||
class ArticlePage(Page):
|
class ArticlePage(Page):
|
||||||
|
template = "core/article_page.html"
|
||||||
body = RichTextField()
|
body = RichTextField()
|
||||||
related_projects = ParentalManyToManyField(
|
related_projects = ParentalManyToManyField(
|
||||||
"core.SkeletonProjectPage", blank=True
|
"core.SkeletonProjectPage", blank=True
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
|
||||||
|
{% if page.get_children.live %}
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for article in page.get_children.live %}
|
||||||
|
<a class="card" href="{% url 'wagtail_serve' '' %}articles/{{ article.slug }}/">
|
||||||
|
<h3>{{ article.title }}</h3>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% include "includes/empty_state.html" with message="No articles yet." %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
|
||||||
|
<div class="rich-text">{{ page.body|safe }}</div>
|
||||||
|
|
||||||
|
{% if page.related_projects.exists %}
|
||||||
|
<h2>Related Projects</h2>
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for project in page.related_projects.all %}
|
||||||
|
<a class="card" href="{% url 'wagtail_serve' '' %}projects/{{ project.slug }}/">
|
||||||
|
<h3>{{ project.title }}</h3>
|
||||||
|
<p>{{ project.summary|truncatewords:20 }}</p>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
|
||||||
|
<form class="filters" method="get">
|
||||||
|
<label>
|
||||||
|
Language:
|
||||||
|
<select name="language">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for lang in languages %}
|
||||||
|
<option value="{{ lang.slug }}" {% if current_language == lang.slug %}selected{% endif %}>{{ lang.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Framework:
|
||||||
|
<select name="framework">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for fw in frameworks %}
|
||||||
|
<option value="{{ fw.slug }}" {% if current_framework == fw.slug %}selected{% endif %}>{{ fw.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Database:
|
||||||
|
<select name="database">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for db in databases %}
|
||||||
|
<option value="{{ db.slug }}" {% if current_database == db.slug %}selected{% endif %}>{{ db.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Min Score:
|
||||||
|
<input type="number" name="min_score" min="0" max="30" value="{{ current_min_score }}" style="width:5ch;">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Search:
|
||||||
|
<input type="text" name="q" value="{{ current_q }}" placeholder="Keyword...">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">Filter</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if projects %}
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for project in projects %}
|
||||||
|
<a class="card" href="{% url 'wagtail_serve' '' %}projects/{{ project.slug }}/">
|
||||||
|
<h3>{{ project.title }}</h3>
|
||||||
|
<p>{{ project.summary|truncatewords:20 }}</p>
|
||||||
|
<div class="score-bar">
|
||||||
|
<span class="score-total">{{ project.total_score }}/30</span>
|
||||||
|
<span class="score-detail">AI-Friendly Score</span>
|
||||||
|
</div>
|
||||||
|
<div class="tag-list">
|
||||||
|
{% for lang in project.languages.all|slice:":3" %}
|
||||||
|
<span class="tag">{{ lang.name }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if projects.paginator.num_pages > 1 %}
|
||||||
|
<nav class="pagination">
|
||||||
|
{% if projects.has_previous %}
|
||||||
|
<a href="?{% for k,v in request.GET.items %}{% if k != 'page' %}{{ k }}={{ v }}&{% endif %}{% endfor %}page={{ projects.previous_page_number }}">« Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
<span class="current">Page {{ projects.number }} of {{ projects.paginator.num_pages }}</span>
|
||||||
|
{% if projects.has_next %}
|
||||||
|
<a href="?{% for k,v in request.GET.items %}{% if k != 'page' %}{{ k }}={{ v }}&{% endif %}{% endfor %}page={{ projects.next_page_number }}">Next »</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% include "includes/empty_state.html" with message="No projects match your filters." %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
|
||||||
|
{% if projects %}
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for project in projects %}
|
||||||
|
<a class="card" href="{% url 'wagtail_serve' '' %}projects/{{ project.slug }}/">
|
||||||
|
<h3>{{ project.title }}</h3>
|
||||||
|
<p>{{ project.summary|truncatewords:20 }}</p>
|
||||||
|
<div class="score-bar">
|
||||||
|
<span class="score-total">{{ project.total_score }}/30</span>
|
||||||
|
<span class="score-detail">AI-Friendly Score</span>
|
||||||
|
</div>
|
||||||
|
<div class="tag-list">
|
||||||
|
{% for lang in project.languages.all|slice:":3" %}
|
||||||
|
<span class="tag">{{ lang.name }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% include "includes/empty_state.html" with message="No projects found in this scenario." %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<h1>
|
||||||
|
{{ page.title }}
|
||||||
|
{% if page.is_featured %}<span class="badge badge-featured">Featured</span>{% endif %}
|
||||||
|
{% if page.is_sponsored %}{% include "includes/sponsored_badge.html" %}{% endif %}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="rich-text">{{ page.summary }}</p>
|
||||||
|
|
||||||
|
<table class="data-table">
|
||||||
|
<tr><th>GitHub</th><td><a href="{{ page.github_url }}" target="_blank" rel="noopener">{{ page.github_url }}</a></td></tr>
|
||||||
|
{% if page.official_url %}
|
||||||
|
<tr><th>Official Site</th><td><a href="{{ page.official_url }}" target="_blank" rel="noopener">{{ page.official_url }}</a></td></tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if page.license_name %}
|
||||||
|
<tr><th>License</th><td>{{ page.license_name }}</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr><th>Maturity</th><td>{{ page.get_maturity_display }}</td></tr>
|
||||||
|
<tr><th>Maintenance</th><td>{{ page.get_maintenance_status_display }}</td></tr>
|
||||||
|
{% if page.scenarios.exists %}
|
||||||
|
<tr><th>Scenarios</th><td>
|
||||||
|
{% for s in page.scenarios.all %}<span class="tag">{{ s.title }}</span>{% endfor %}
|
||||||
|
</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if page.languages.exists %}
|
||||||
|
<tr><th>Languages</th><td>
|
||||||
|
{% for l in page.languages.all %}<span class="tag">{{ l.name }}</span>{% endfor %}
|
||||||
|
</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if page.frameworks.exists %}
|
||||||
|
<tr><th>Frameworks</th><td>
|
||||||
|
{% for f in page.frameworks.all %}<span class="tag">{{ f.name }}</span>{% endfor %}
|
||||||
|
</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if page.databases.exists %}
|
||||||
|
<tr><th>Databases</th><td>
|
||||||
|
{% for d in page.databases.all %}<span class="tag">{{ d.name }}</span>{% endfor %}
|
||||||
|
</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if page.features.exists %}
|
||||||
|
<tr><th>Features</th><td>
|
||||||
|
{% for feat in page.features.all %}<span class="tag">{{ feat.name }}</span>{% endfor %}
|
||||||
|
</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Scores</h2>
|
||||||
|
<table class="data-table">
|
||||||
|
<tr><th>Structure</th><td>{{ page.structure_score }}/5</td></tr>
|
||||||
|
<tr><th>Documentation</th><td>{{ page.docs_score }}/5</td></tr>
|
||||||
|
<tr><th>Testing</th><td>{{ page.tests_score }}/5</td></tr>
|
||||||
|
<tr><th>Examples</th><td>{{ page.example_score }}/5</td></tr>
|
||||||
|
<tr><th>Dependency Control</th><td>{{ page.dependency_score }}/5</td></tr>
|
||||||
|
<tr><th>Incremental Development</th><td>{{ page.incremental_score }}/5</td></tr>
|
||||||
|
<tr><th>Total</th><td><strong>{{ page.total_score }}/30</strong></td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Recommended For</h2>
|
||||||
|
<div class="rich-text">{{ page.recommended_for|safe }}</div>
|
||||||
|
|
||||||
|
{% if page.not_recommended_for %}
|
||||||
|
<h2>Not Recommended For</h2>
|
||||||
|
<div class="rich-text">{{ page.not_recommended_for|safe }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if page.review_notes %}
|
||||||
|
<h2>Review Notes</h2>
|
||||||
|
<div class="rich-text">{{ page.review_notes|safe }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
+200
-17
@@ -20,6 +20,206 @@ from core.models import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PageTreeMixin:
|
||||||
|
@classmethod
|
||||||
|
def setUpPageTree(cls):
|
||||||
|
cls.home = HomePage.objects.get(slug="home")
|
||||||
|
|
||||||
|
cls.scenario_index = ScenarioIndexPage(title="Scenarios", slug="scenarios")
|
||||||
|
cls.home.add_child(instance=cls.scenario_index)
|
||||||
|
cls.scenario = ScenarioPage(title="SaaS", slug="saas")
|
||||||
|
cls.scenario_index.add_child(instance=cls.scenario)
|
||||||
|
|
||||||
|
cls.project_index = ProjectIndexPage(title="Projects", slug="projects")
|
||||||
|
cls.home.add_child(instance=cls.project_index)
|
||||||
|
|
||||||
|
cls.article_index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
cls.home.add_child(instance=cls.article_index)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioPageTests(PageTreeMixin, TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.setUpPageTree()
|
||||||
|
cls.lang = Language.objects.create(name="Python", slug="python")
|
||||||
|
|
||||||
|
def test_scenario_page_with_projects(self):
|
||||||
|
project = SkeletonProjectPage(
|
||||||
|
title="Scenario Project",
|
||||||
|
slug="scenario-project",
|
||||||
|
summary="A project in this scenario",
|
||||||
|
github_url="https://github.com/test/sproject",
|
||||||
|
maturity="stable",
|
||||||
|
recommended_for="Testing",
|
||||||
|
structure_score=3, docs_score=3, tests_score=3,
|
||||||
|
example_score=3, dependency_score=3, incremental_score=3,
|
||||||
|
)
|
||||||
|
self.project_index.add_child(instance=project)
|
||||||
|
project.languages.add(self.lang)
|
||||||
|
project.scenarios.add(self.scenario)
|
||||||
|
project.save()
|
||||||
|
|
||||||
|
response = self.client.get(self.scenario.url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Scenario Project")
|
||||||
|
|
||||||
|
def test_empty_scenario_page(self):
|
||||||
|
ScenarioPage.objects.create(
|
||||||
|
title="Empty Scenario",
|
||||||
|
slug="empty-scenario",
|
||||||
|
path=self.scenario_index.path + "9999",
|
||||||
|
depth=self.scenario_index.depth + 1,
|
||||||
|
)
|
||||||
|
response = self.client.get("/scenarios/empty-scenario/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Nothing here yet")
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectFilterTests(PageTreeMixin, TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.setUpPageTree()
|
||||||
|
cls.lang_py = Language.objects.create(name="Python", slug="python")
|
||||||
|
cls.lang_go = Language.objects.create(name="Go", slug="go")
|
||||||
|
cls.fw_dj = Framework.objects.create(name="Django", slug="django")
|
||||||
|
cls.db_pg = DatabaseOption.objects.create(name="PostgreSQL", slug="postgresql")
|
||||||
|
|
||||||
|
cls.proj_a = SkeletonProjectPage(
|
||||||
|
title="Django Project",
|
||||||
|
slug="django-project",
|
||||||
|
summary="A Django project for testing",
|
||||||
|
github_url="https://github.com/test/django",
|
||||||
|
maturity="stable",
|
||||||
|
recommended_for="Testing",
|
||||||
|
structure_score=5, docs_score=5, tests_score=5,
|
||||||
|
example_score=5, dependency_score=5, incremental_score=5,
|
||||||
|
)
|
||||||
|
cls.project_index.add_child(instance=cls.proj_a)
|
||||||
|
cls.proj_a.languages.add(cls.lang_py)
|
||||||
|
cls.proj_a.frameworks.add(cls.fw_dj)
|
||||||
|
cls.proj_a.databases.add(cls.db_pg)
|
||||||
|
cls.proj_a.scenarios.add(cls.scenario)
|
||||||
|
cls.proj_a.save()
|
||||||
|
|
||||||
|
cls.proj_b = SkeletonProjectPage(
|
||||||
|
title="Go Project",
|
||||||
|
slug="go-project",
|
||||||
|
summary="A Go project for testing",
|
||||||
|
github_url="https://github.com/test/go",
|
||||||
|
maturity="experimental",
|
||||||
|
recommended_for="Testing",
|
||||||
|
structure_score=1, docs_score=1, tests_score=1,
|
||||||
|
example_score=1, dependency_score=1, incremental_score=1,
|
||||||
|
)
|
||||||
|
cls.project_index.add_child(instance=cls.proj_b)
|
||||||
|
cls.proj_b.languages.add(cls.lang_go)
|
||||||
|
cls.proj_b.scenarios.add(cls.scenario)
|
||||||
|
cls.proj_b.save()
|
||||||
|
|
||||||
|
def test_language_filter(self):
|
||||||
|
resp = self.client.get("/projects/?language=python")
|
||||||
|
self.assertContains(resp, "Django Project")
|
||||||
|
self.assertNotContains(resp, "Go Project")
|
||||||
|
|
||||||
|
def test_min_score_filter(self):
|
||||||
|
resp = self.client.get("/projects/?min_score=18")
|
||||||
|
self.assertContains(resp, "Django Project")
|
||||||
|
self.assertNotContains(resp, "Go Project")
|
||||||
|
|
||||||
|
def test_keyword_search(self):
|
||||||
|
resp = self.client.get("/projects/?q=Django")
|
||||||
|
self.assertContains(resp, "Django Project")
|
||||||
|
self.assertNotContains(resp, "Go Project")
|
||||||
|
|
||||||
|
def test_invalid_params_ignored(self):
|
||||||
|
resp = self.client.get("/projects/?min_score=abc&page=xyz")
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class SkeletonProjectDetailTests(PageTreeMixin, TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.setUpPageTree()
|
||||||
|
cls.lang = Language.objects.create(name="Ruby", slug="ruby")
|
||||||
|
|
||||||
|
def test_detail_shows_scores_and_recommendation(self):
|
||||||
|
project = SkeletonProjectPage(
|
||||||
|
title="Detail Test",
|
||||||
|
slug="detail-test",
|
||||||
|
summary="Testing detail page",
|
||||||
|
github_url="https://github.com/test/detail",
|
||||||
|
maturity="stable",
|
||||||
|
recommended_for="Developers who need structure",
|
||||||
|
structure_score=4, docs_score=3, tests_score=3,
|
||||||
|
example_score=4, dependency_score=3, incremental_score=4,
|
||||||
|
)
|
||||||
|
self.project_index.add_child(instance=project)
|
||||||
|
project.languages.add(self.lang)
|
||||||
|
project.scenarios.add(self.scenario)
|
||||||
|
project.save()
|
||||||
|
|
||||||
|
resp = self.client.get(project.url)
|
||||||
|
self.assertContains(resp, "Detail Test")
|
||||||
|
self.assertContains(resp, "21/30")
|
||||||
|
self.assertContains(resp, "Developers who need structure")
|
||||||
|
|
||||||
|
def test_sponsored_badge(self):
|
||||||
|
project = SkeletonProjectPage(
|
||||||
|
title="Sponsored Project",
|
||||||
|
slug="sponsored-project",
|
||||||
|
summary="A sponsored test",
|
||||||
|
github_url="https://github.com/test/sponsored",
|
||||||
|
maturity="experimental",
|
||||||
|
recommended_for="Sponsored users",
|
||||||
|
structure_score=2, docs_score=2, tests_score=2,
|
||||||
|
example_score=2, dependency_score=2, incremental_score=2,
|
||||||
|
is_sponsored=True,
|
||||||
|
)
|
||||||
|
self.project_index.add_child(instance=project)
|
||||||
|
project.languages.add(self.lang)
|
||||||
|
project.scenarios.add(self.scenario)
|
||||||
|
project.save()
|
||||||
|
|
||||||
|
resp = self.client.get(project.url)
|
||||||
|
self.assertContains(resp, "Sponsored")
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleDetailTests(PageTreeMixin, TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.setUpPageTree()
|
||||||
|
cls.lang = Language.objects.create(name="Rust", slug="rust")
|
||||||
|
|
||||||
|
def test_article_links_to_project(self):
|
||||||
|
project = SkeletonProjectPage(
|
||||||
|
title="Linked Project",
|
||||||
|
slug="linked-project",
|
||||||
|
summary="Project linked from article",
|
||||||
|
github_url="https://github.com/test/linked",
|
||||||
|
maturity="stable",
|
||||||
|
recommended_for="Testing",
|
||||||
|
structure_score=3, docs_score=3, tests_score=3,
|
||||||
|
example_score=3, dependency_score=3, incremental_score=3,
|
||||||
|
)
|
||||||
|
self.project_index.add_child(instance=project)
|
||||||
|
project.languages.add(self.lang)
|
||||||
|
project.scenarios.add(self.scenario)
|
||||||
|
project.save()
|
||||||
|
|
||||||
|
article = ArticlePage(
|
||||||
|
title="Review Article",
|
||||||
|
slug="review-article",
|
||||||
|
body="<p>About the linked project.</p>",
|
||||||
|
)
|
||||||
|
self.article_index.add_child(instance=article)
|
||||||
|
article.related_projects.add(project)
|
||||||
|
article.save()
|
||||||
|
|
||||||
|
resp = self.client.get(article.url)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertContains(resp, "/projects/linked-project/")
|
||||||
|
|
||||||
|
|
||||||
class LanguageTests(TestCase):
|
class LanguageTests(TestCase):
|
||||||
def test_create_language(self):
|
def test_create_language(self):
|
||||||
lang = Language.objects.create(name="Python", slug="python")
|
lang = Language.objects.create(name="Python", slug="python")
|
||||||
@@ -64,23 +264,6 @@ class SkeletonFeatureTests(TestCase):
|
|||||||
SkeletonFeature.objects.create(name="Docker2", slug="docker")
|
SkeletonFeature.objects.create(name="Docker2", slug="docker")
|
||||||
|
|
||||||
|
|
||||||
class PageTreeMixin:
|
|
||||||
@classmethod
|
|
||||||
def setUpPageTree(cls):
|
|
||||||
cls.home = HomePage.objects.get(slug="home")
|
|
||||||
|
|
||||||
cls.scenario_index = ScenarioIndexPage(title="Scenarios", slug="scenarios")
|
|
||||||
cls.home.add_child(instance=cls.scenario_index)
|
|
||||||
cls.scenario = ScenarioPage(title="SaaS", slug="saas")
|
|
||||||
cls.scenario_index.add_child(instance=cls.scenario)
|
|
||||||
|
|
||||||
cls.project_index = ProjectIndexPage(title="Projects", slug="projects")
|
|
||||||
cls.home.add_child(instance=cls.project_index)
|
|
||||||
|
|
||||||
cls.article_index = ArticleIndexPage(title="Articles", slug="articles")
|
|
||||||
cls.home.add_child(instance=cls.article_index)
|
|
||||||
|
|
||||||
|
|
||||||
class SkeletonProjectPageTests(PageTreeMixin, TestCase):
|
class SkeletonProjectPageTests(PageTreeMixin, TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
|||||||
+6
-6
@@ -39,12 +39,12 @@
|
|||||||
|
|
||||||
| ID | 任务 | 依赖 | 验收要点 | 状态 |
|
| ID | 任务 | 依赖 | 验收要点 | 状态 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| T-201 | 实现基础页面框架 | T-104 | `base.html`、导航、页脚、纯 CSS 基础样式;含 viewport meta;无超过视口的固定宽度容器 | TODO |
|
| T-201 | 实现基础页面框架 | T-104 | `base.html`、导航、页脚、纯 CSS 基础样式;含 viewport meta;无超过视口的固定宽度容器 | DONE |
|
||||||
| T-202 | 实现首页 | T-201 | 首页展示场景入口、推荐骨架和最新文章 | TODO |
|
| T-202 | 实现首页 | T-201 | 首页展示场景入口、推荐骨架和最新文章 | DONE |
|
||||||
| T-203 | 实现场景页和项目列表页 | T-202 | 场景页展示对应项目;项目列表支持分页和空状态 | TODO |
|
| T-203 | 实现场景页和项目列表页 | T-202 | 场景页展示对应项目;项目列表支持分页和空状态 | DONE |
|
||||||
| T-204 | 实现筛选与基础搜索 | T-203 | 严格按 `04-architecture.md` §3.4 参数契约实现筛选和关键词搜索 | TODO |
|
| T-204 | 实现筛选与基础搜索 | T-203 | 严格按 `04-architecture.md` §3.4 参数契约实现筛选和关键词搜索 | DONE |
|
||||||
| T-205 | 实现骨架详情页 | T-204 | 展示需求文档要求的详情字段、评分、推荐理由和 Sponsored 标识 | TODO |
|
| T-205 | 实现骨架详情页 | T-204 | 展示需求文档要求的详情字段、评分、推荐理由和 Sponsored 标识 | DONE |
|
||||||
| T-206 | 实现文章列表和文章详情 | T-203 | 文章可访问,可链接项目详情 | TODO |
|
| T-206 | 实现文章列表和文章详情 | T-203 | 文章可访问,可链接项目详情 | DONE |
|
||||||
|
|
||||||
## Phase 3 · SEO 与上线准备
|
## Phase 3 · SEO 与上线准备
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,14 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-06
|
- 日期:2026-07-06
|
||||||
- 阶段:T-001 已完成,Wagtail 7.4.2 + Django 6.0.6 项目已初始化;生产代码可运行
|
- 阶段:Phase 2(前台 MVP)已完成,T-201 至 T-206 全部实现
|
||||||
- 开发环境:MSYS2/MinGW Python 3.12.12,venv 使用 `--system-site-packages`(Pillow 由 MSYS2 预编译包提供)
|
- 开发环境:MSYS2/MinGW Python 3.12.12,venv 使用 `--system-site-packages`(Pillow 由 MSYS2 预编译包提供)
|
||||||
- git:分支 `ds`;`.gitignore` 已生效(`.venv/`、`db.sqlite3`、`media/` 等被忽略)
|
- git:分支 `ds`;`.gitignore` 已生效(`.venv/`、`db.sqlite3`、`media/` 等被忽略)
|
||||||
- 技术栈:Wagtail 7.4.2 + Django 6.0.6 + Python 3.12 + SQLite;第一版英文单语言站点
|
- 技术栈:Wagtail 7.4.2 + Django 6.0.6 + Python 3.12 + SQLite;第一版英文单语言站点
|
||||||
- 生产代码:已初始化,`manage.py`、`skelet/`、`home/`、`search/`、`core/` 已建
|
- 生产代码:已初始化,`manage.py`、`skelet/`、`home/`、`search/`、`core/` 已建
|
||||||
- 测试:`home/tests.py` 含 5 个测试,`core/tests.py` 含 17 个测试,全部通过
|
- 测试:`home/tests.py` 含 5 个测试,`core/tests.py` 含 17 个测试,全部通过
|
||||||
- 数据:3 个场景、5 个骨架项目、2 篇文章已录入(`core/management/commands/seed_data.py`)
|
- 数据:3 个场景、5 个骨架项目、2 篇文章已录入(`core/management/commands/seed_data.py`)
|
||||||
- 当前 blocker:无;下一步执行 `T-201`(Phase 2 前台 MVP)
|
- 当前 blocker:无;下一步执行 `T-301`(Phase 3 SEO 与上线准备)
|
||||||
|
|
||||||
## 当前目录要点
|
## 当前目录要点
|
||||||
|
|
||||||
@@ -42,9 +42,9 @@
|
|||||||
|
|
||||||
## 任务看板状态
|
## 任务看板状态
|
||||||
|
|
||||||
- 已完成:`T-000`、`T-001`、`T-002`、`T-003`、`T-101`、`T-102`、`T-103`、`T-104`。
|
- 已完成:T-000 至 T-104(Phase 0+1)、T-201 至 T-206(Phase 2)。
|
||||||
- 正在进行:无。
|
- 正在进行:无。
|
||||||
- 下一个可领取任务:`T-201 实现基础页面框架`。
|
- 下一个可领取任务:`T-301 补 SEO 基础`(Phase 3)。
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -2,6 +2,23 @@ from django.db import models
|
|||||||
|
|
||||||
from wagtail.models import Page
|
from wagtail.models import Page
|
||||||
|
|
||||||
|
from core.models import ScenarioPage, SkeletonProjectPage, ArticlePage
|
||||||
|
|
||||||
|
|
||||||
class HomePage(Page):
|
class HomePage(Page):
|
||||||
pass
|
parent_page_types = ["wagtailcore.Page"]
|
||||||
|
max_count = 1
|
||||||
|
|
||||||
|
def get_context(self, request, *args, **kwargs):
|
||||||
|
context = super().get_context(request, *args, **kwargs)
|
||||||
|
context["scenarios"] = ScenarioPage.objects.live()
|
||||||
|
context["featured_projects"] = (
|
||||||
|
SkeletonProjectPage.objects.live().filter(is_featured=True)
|
||||||
|
.prefetch_related("scenarios", "languages")
|
||||||
|
.order_by("-first_published_at")[:6]
|
||||||
|
)
|
||||||
|
context["latest_articles"] = (
|
||||||
|
ArticlePage.objects.live()
|
||||||
|
.order_by("-first_published_at")[:4]
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|||||||
@@ -1,21 +1,64 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block body_class %}template-homepage{% endblock %}
|
{% block body_class %}template-homepage{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
|
|
||||||
{% comment %}
|
|
||||||
Delete the line below if you're just getting started and want to remove the welcome screen!
|
|
||||||
{% endcomment %}
|
|
||||||
<link rel="stylesheet" href="{% static 'css/welcome_page.css' %}">
|
|
||||||
{% endblock extra_css %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{% comment %}
|
<h1>Find the Right Project Skeleton</h1>
|
||||||
Delete the line below if you're just getting started and want to remove the welcome screen!
|
<p>Browse open-source project skeletons by scenario, language, framework, and AI-coding friendliness score.</p>
|
||||||
{% endcomment %}
|
|
||||||
{% include 'home/welcome_page.html' %}
|
{% if scenarios %}
|
||||||
|
<section>
|
||||||
|
<h2>Scenarios</h2>
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for scenario in scenarios %}
|
||||||
|
<a class="card" href="{% url 'wagtail_serve' '' %}scenarios/{{ scenario.slug }}/">
|
||||||
|
<h3>{{ scenario.title }}</h3>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if featured_projects %}
|
||||||
|
<section>
|
||||||
|
<h2>Featured Projects</h2>
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for project in featured_projects %}
|
||||||
|
<a class="card" href="{% url 'wagtail_serve' '' %}projects/{{ project.slug }}/">
|
||||||
|
<h3>
|
||||||
|
{{ project.title }}
|
||||||
|
{% if project.is_sponsored %}
|
||||||
|
<span class="badge badge-sponsored">Sponsored</span>
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
<p>{{ project.summary|truncatewords:20 }}</p>
|
||||||
|
<div class="score-bar">
|
||||||
|
<span class="score-total">{{ project.total_score }}/30</span>
|
||||||
|
<span class="score-detail">AI-Friendly Score</span>
|
||||||
|
</div>
|
||||||
|
<div class="tag-list">
|
||||||
|
{% for lang in project.languages.all|slice:":3" %}
|
||||||
|
<span class="tag">{{ lang.name }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if latest_articles %}
|
||||||
|
<section>
|
||||||
|
<h2>Latest Articles</h2>
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for article in latest_articles %}
|
||||||
|
<a class="card" href="{% url 'wagtail_serve' '' %}articles/{{ article.slug }}/">
|
||||||
|
<h3>{{ article.title }}</h3>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
+52
-14
@@ -5,6 +5,14 @@ from home.models import HomePage
|
|||||||
from wagtail.models import Page, Site
|
from wagtail.models import Page, Site
|
||||||
from wagtail.test.utils import WagtailPageTestCase
|
from wagtail.test.utils import WagtailPageTestCase
|
||||||
|
|
||||||
|
from core.models import (
|
||||||
|
Language,
|
||||||
|
ScenarioPage,
|
||||||
|
SkeletonProjectPage,
|
||||||
|
ScenarioIndexPage,
|
||||||
|
ProjectIndexPage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Smoketest(TestCase):
|
class Smoketest(TestCase):
|
||||||
def test_homepage_returns_200(self):
|
def test_homepage_returns_200(self):
|
||||||
@@ -12,33 +20,63 @@ class Smoketest(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
class HomeSetUpTests(WagtailPageTestCase):
|
class HomepageContextTests(TestCase):
|
||||||
"""
|
@classmethod
|
||||||
Tests for basic page structure setup and HomePage creation.
|
def setUpTestData(cls):
|
||||||
"""
|
root = Page.get_first_root_node()
|
||||||
|
cls.home = HomePage.objects.get(slug="home")
|
||||||
|
|
||||||
|
scenario_index = ScenarioIndexPage(title="Scenarios", slug="scenarios")
|
||||||
|
cls.home.add_child(instance=scenario_index)
|
||||||
|
|
||||||
|
cls.scenario = ScenarioPage(title="Test Scenario", slug="test-scenario")
|
||||||
|
scenario_index.add_child(instance=cls.scenario)
|
||||||
|
|
||||||
|
project_index = ProjectIndexPage(title="Projects", slug="projects")
|
||||||
|
cls.home.add_child(instance=project_index)
|
||||||
|
|
||||||
|
lang = Language.objects.create(name="Python", slug="python")
|
||||||
|
|
||||||
|
project = SkeletonProjectPage(
|
||||||
|
title="Featured Project",
|
||||||
|
slug="featured-project",
|
||||||
|
summary="A featured test project",
|
||||||
|
github_url="https://github.com/test/featured",
|
||||||
|
maturity="stable",
|
||||||
|
recommended_for="Testing",
|
||||||
|
structure_score=4, docs_score=4, tests_score=3,
|
||||||
|
example_score=3, dependency_score=3, incremental_score=3,
|
||||||
|
is_featured=True,
|
||||||
|
)
|
||||||
|
project_index.add_child(instance=project)
|
||||||
|
project.languages.add(lang)
|
||||||
|
project.scenarios.add(cls.scenario)
|
||||||
|
project.save()
|
||||||
|
|
||||||
|
def test_homepage_contains_featured_project(self):
|
||||||
|
response = self.client.get("/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Featured Project")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeSetUpTests(WagtailPageTestCase):
|
||||||
def test_root_create(self):
|
def test_root_create(self):
|
||||||
root_page = Page.objects.get(pk=1)
|
root_page = Page.objects.get(pk=1)
|
||||||
self.assertIsNotNone(root_page)
|
self.assertIsNotNone(root_page)
|
||||||
|
|
||||||
def test_homepage_create(self):
|
def test_homepage_create(self):
|
||||||
root_page = Page.objects.get(pk=1)
|
root_page = Page.objects.get(pk=1)
|
||||||
homepage = HomePage(title="Home")
|
homepage = HomePage(title="Home Test")
|
||||||
root_page.add_child(instance=homepage)
|
root_page.add_child(instance=homepage)
|
||||||
self.assertTrue(HomePage.objects.filter(title="Home").exists())
|
self.assertTrue(HomePage.objects.filter(title="Home Test").exists())
|
||||||
|
|
||||||
|
|
||||||
class HomeTests(WagtailPageTestCase):
|
class HomeTests(WagtailPageTestCase):
|
||||||
"""
|
|
||||||
Tests for homepage functionality and rendering.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""
|
|
||||||
Create a homepage instance for testing.
|
|
||||||
"""
|
|
||||||
root_page = Page.get_first_root_node()
|
root_page = Page.get_first_root_node()
|
||||||
Site.objects.create(hostname="testsite", root_page=root_page, is_default_site=True)
|
Site.objects.create(
|
||||||
|
hostname="testsite", root_page=root_page, is_default_site=True
|
||||||
|
)
|
||||||
self.homepage = HomePage(title="Home")
|
self.homepage = HomePage(title="Home")
|
||||||
root_page.add_child(instance=self.homepage)
|
root_page.add_child(instance=self.homepage)
|
||||||
|
|
||||||
|
|||||||
@@ -259,3 +259,11 @@
|
|||||||
- 阻塞:无。
|
- 阻塞:无。
|
||||||
- 下一步:T-201 实现基础页面框架(Phase 2 前台 MVP)。
|
- 下一步:T-201 实现基础页面框架(Phase 2 前台 MVP)。
|
||||||
|
|
||||||
|
## 2026-07-06 T-201 至 T-206 Phase 2 前台 MVP
|
||||||
|
|
||||||
|
- 状态:全部 DONE
|
||||||
|
- 变更:T-201 基础框架(base.html + nav/footer + CSS),T-202 首页(scenarios/featured/articles),T-203 场景页 + 项目列表(分页+空状态),T-204 筛选搜索(§3.4 契约),T-205 详情页(scores/badge),T-206 文章列表+详情(含 project links)
|
||||||
|
- 验证:`manage.py test core home` 31 tests passed
|
||||||
|
- 阻塞:无。
|
||||||
|
- 下一步:T-301 补 SEO 基础(Phase 3)。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
/* Reset */
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
color: #1a1a2e;
|
||||||
|
background: #fafafa;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav */
|
||||||
|
.site-nav {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav .container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main */
|
||||||
|
.site-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.site-footer {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
h1, h2, h3 {
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
|
||||||
|
h2 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||||
|
h3 { font-size: 1.25rem; margin-bottom: 0.375rem; }
|
||||||
|
|
||||||
|
p { margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
a { color: #2563eb; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Card grid */
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-sponsored {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-featured {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Score bar */
|
||||||
|
.score-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-total {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-detail {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h2 {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination a,
|
||||||
|
.pagination span {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .current {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.filters {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters select,
|
||||||
|
.filters input {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters button {
|
||||||
|
padding: 0.375rem 1rem;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters button:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rich text */
|
||||||
|
.rich-text h1 { font-size: 2rem; margin: 1.5rem 0 0.5rem; }
|
||||||
|
.rich-text h2 { font-size: 1.5rem; margin: 1.25rem 0 0.5rem; }
|
||||||
|
.rich-text h3 { font-size: 1.25rem; margin: 1rem 0 0.375rem; }
|
||||||
|
.rich-text ul, .rich-text ol { padding-left: 1.5rem; margin-bottom: 1rem; }
|
||||||
|
.rich-text li { margin-bottom: 0.25rem; }
|
||||||
|
|||||||
+15
-14
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>
|
<title>
|
||||||
{% block title %}
|
{% block title %}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block title_suffix %}
|
{% block title_suffix %}
|
||||||
{% wagtail_site as current_site %}
|
{% wagtail_site as current_site %}
|
||||||
{% if current_site and current_site.site_name %}- {{ current_site.site_name }}{% endif %}
|
{% if current_site and current_site.site_name %}– {{ current_site.site_name }}{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</title>
|
</title>
|
||||||
{% if page.search_description %}
|
{% if page.search_description %}
|
||||||
@@ -18,29 +18,30 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
|
||||||
{# Force all links in the live preview panel to be opened in a new tab #}
|
|
||||||
{% if request.in_preview_panel %}
|
{% if request.in_preview_panel %}
|
||||||
<base target="_blank">
|
<base target="_blank">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Global stylesheets #}
|
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'css/skelet.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'css/skelet.css' %}">
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}{% endblock %}
|
||||||
{# Override this in templates to add extra stylesheets #}
|
</head>
|
||||||
{% endblock %}
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="{% block body_class %}{% endblock %}">
|
<body class="{% block body_class %}{% endblock %}">
|
||||||
{% wagtailuserbar %}
|
{% wagtailuserbar %}
|
||||||
|
|
||||||
|
{% include "includes/nav.html" %}
|
||||||
|
|
||||||
|
<main class="site-main">
|
||||||
|
<div class="container">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{% include "includes/footer.html" %}
|
||||||
|
|
||||||
{# Global javascript #}
|
|
||||||
<script type="text/javascript" src="{% static 'js/skelet.js' %}"></script>
|
<script type="text/javascript" src="{% static 'js/skelet.js' %}"></script>
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}{% endblock %}
|
||||||
{# Override this in templates to add extra javascript #}
|
</body>
|
||||||
{% endblock %}
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="empty-state">
|
||||||
|
<h2>Nothing here yet</h2>
|
||||||
|
<p>{{ message|default:"No content available." }}</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<p>© {% now "Y" %} Skelet. Helping developers find the right project skeleton.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<nav class="site-nav">
|
||||||
|
<div class="container">
|
||||||
|
<a href="/" class="nav-logo">Skelet</a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="/scenarios/">Scenarios</a></li>
|
||||||
|
<li><a href="/projects/">Projects</a></li>
|
||||||
|
<li><a href="/articles/">Articles</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<span class="badge badge-sponsored">Sponsored</span>
|
||||||
Reference in New Issue
Block a user