Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
c559e6fffc 1 2026-04-14 16:55:47 +00:00
35 changed files with 2966 additions and 198 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,3 +1,27 @@
from django.contrib import admin
# Register your models here.
from .models import Article, NewsSource, Topic
@admin.register(Topic)
class TopicAdmin(admin.ModelAdmin):
list_display = ('name', 'accent_color')
search_fields = ('name', 'description')
prepopulated_fields = {'slug': ('name',)}
@admin.register(NewsSource)
class NewsSourceAdmin(admin.ModelAdmin):
list_display = ('name', 'is_active', 'last_synced_at')
list_filter = ('is_active',)
search_fields = ('name', 'feed_url', 'description')
prepopulated_fields = {'slug': ('name',)}
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ('title', 'article_kind', 'topic', 'source', 'is_featured', 'is_published', 'published_at')
list_filter = ('article_kind', 'topic', 'is_featured', 'is_published', 'source')
search_fields = ('title', 'excerpt', 'content', 'author_name', 'external_url')
prepopulated_fields = {'slug': ('title',)}
autocomplete_fields = ('topic', 'source')

62
core/forms.py Normal file
View File

@ -0,0 +1,62 @@
from django import forms
from .models import Article, Topic
class ArticleFilterForm(forms.Form):
q = forms.CharField(
required=False,
max_length=120,
label='Search',
widget=forms.TextInput(
attrs={
'placeholder': 'Search startup, AI, funding, product…',
'class': 'form-control form-control-lg search-input',
}
),
)
topic = forms.ChoiceField(
required=False,
label='Topic',
widget=forms.Select(attrs={'class': 'form-select form-select-lg'}),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
topic_choices = [('', 'All topics')]
topic_choices.extend((topic.slug, topic.name) for topic in Topic.objects.order_by('name'))
self.fields['topic'].choices = topic_choices
class OriginalArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ['title', 'topic', 'author_name', 'excerpt', 'content', 'is_featured']
widgets = {
'title': forms.TextInput(attrs={'class': 'form-control form-control-lg', 'placeholder': 'Write a sharp headline'}),
'topic': forms.Select(attrs={'class': 'form-select form-select-lg'}),
'author_name': forms.TextInput(attrs={'class': 'form-control form-control-lg', 'placeholder': 'Editor or columnist name'}),
'excerpt': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'A short dek for cards and search results'}),
'content': forms.Textarea(attrs={'class': 'form-control', 'rows': 10, 'placeholder': 'Draft the story, analysis, or curated roundup'}),
'is_featured': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
labels = {
'excerpt': 'Deck / summary',
'is_featured': 'Pin as featured story',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['topic'].queryset = Topic.objects.order_by('name')
self.fields['topic'].empty_label = 'Choose a topic'
self.fields['topic'].required = True
def clean(self):
cleaned_data = super().clean()
excerpt = cleaned_data.get('excerpt', '')
content = cleaned_data.get('content', '')
if len(excerpt.strip()) < 20:
self.add_error('excerpt', 'Please add at least 20 characters so cards feel editorially complete.')
if len(content.strip()) < 80:
self.add_error('content', 'Please write at least 80 characters to create a publishable story.')
return cleaned_data

View File

Binary file not shown.

View File

View File

@ -0,0 +1,15 @@
from django.core.management.base import BaseCommand
from core.rss import sync_active_sources
class Command(BaseCommand):
help = "Import active RSS feeds into newsroom articles"
def add_arguments(self, parser):
parser.add_argument('--limit', type=int, default=8, help='Number of feed items per source')
parser.add_argument('--stale-minutes', type=int, default=0, help='Only sync sources older than this many minutes')
def handle(self, *args, **options):
created = sync_active_sources(limit=options['limit'], stale_minutes=options['stale_minutes'])
self.stdout.write(self.style.SUCCESS(f'Imported {created} article(s).'))

View File

@ -0,0 +1,71 @@
# Generated by Django 5.2.7 on 2026-04-14 16:42
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='NewsSource',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120, unique=True)),
('slug', models.SlugField(max_length=140, unique=True)),
('site_url', models.URLField(blank=True)),
('feed_url', models.URLField(unique=True)),
('description', models.TextField(blank=True)),
('is_active', models.BooleanField(default=True)),
('last_synced_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Topic',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=80, unique=True)),
('slug', models.SlugField(max_length=90, unique=True)),
('description', models.TextField(blank=True)),
('accent_color', models.CharField(default='#00c2a8', max_length=7)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Article',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=220)),
('slug', models.SlugField(max_length=240, unique=True)),
('excerpt', models.TextField(blank=True)),
('content', models.TextField(blank=True)),
('article_kind', models.CharField(choices=[('rss', 'RSS import'), ('original', 'Original story')], default='rss', max_length=20)),
('external_url', models.URLField(blank=True)),
('image_url', models.URLField(blank=True)),
('author_name', models.CharField(blank=True, max_length=120)),
('published_at', models.DateTimeField(default=django.utils.timezone.now)),
('dedupe_key', models.CharField(blank=True, max_length=64, unique=True)),
('is_featured', models.BooleanField(default=False)),
('is_published', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('source', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='articles', to='core.newssource')),
('topic', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='articles', to='core.topic')),
],
options={
'ordering': ['-published_at', '-created_at'],
},
),
]

View File

@ -0,0 +1,88 @@
from django.db import migrations
from django.utils import timezone
def seed_newsroom(apps, schema_editor):
Topic = apps.get_model('core', 'Topic')
NewsSource = apps.get_model('core', 'NewsSource')
Article = apps.get_model('core', 'Article')
topics = [
('Artificial Intelligence', 'artificial-intelligence', 'Model launches, research, tooling, and applied AI.', '#00c2a8'),
('Startups', 'startups', 'Founders, launches, product momentum, and operator notes.', '#ff6b4a'),
('Venture Capital', 'venture-capital', 'Funding rounds, investors, and deal flow.', '#7df9d7'),
('Product & Cloud', 'product-cloud', 'SaaS, developer platforms, cloud infrastructure, and enterprise software.', '#ffd166'),
('Hardware', 'hardware', 'Devices, chips, robotics, and the physical layer of tech.', '#8ec5ff'),
('Security', 'security', 'Cybersecurity, privacy, and resilience across the stack.', '#ff8fab'),
]
for name, slug, description, accent_color in topics:
Topic.objects.update_or_create(
slug=slug,
defaults={
'name': name,
'description': description,
'accent_color': accent_color,
},
)
sources = [
('TechCrunch', 'techcrunch', 'https://techcrunch.com', 'https://techcrunch.com/feed/', 'Startup and technology reporting.'),
('The Verge', 'the-verge', 'https://www.theverge.com', 'https://www.theverge.com/rss/index.xml', 'Product, platform, and consumer tech news.'),
('Wired', 'wired', 'https://www.wired.com', 'https://www.wired.com/feed/rss', 'Culture, technology, and future-of-tech coverage.'),
('Ars Technica', 'ars-technica', 'https://arstechnica.com', 'https://feeds.arstechnica.com/arstechnica/index', 'Deep reporting across science, policy, and hardware.'),
]
for name, slug, site_url, feed_url, description in sources:
NewsSource.objects.update_or_create(
slug=slug,
defaults={
'name': name,
'site_url': site_url,
'feed_url': feed_url,
'description': description,
'is_active': True,
},
)
topic = Topic.objects.filter(slug='startups').first()
Article.objects.update_or_create(
slug='inside-signal-how-to-run-a-fast-tech-newsroom',
defaults={
'title': 'Inside Signal: how to run a fast, future-ready tech newsroom',
'excerpt': 'A seeded original story so the editorial flow is visible even before the first RSS sync completes.',
'content': 'Signal combines live feed aggregation with a lightweight publishing flow. Editors can scan imported stories, publish original analysis, and shape the front page with featured picks. This seeded story exists to make the first delivery feel complete while you continue building the full magazine.',
'article_kind': 'original',
'topic': topic,
'author_name': 'Signal Editorial Desk',
'published_at': timezone.now(),
'dedupe_key': 'seeded-signal-editorial-launch-story',
'is_featured': True,
'is_published': True,
},
)
def unseed_newsroom(apps, schema_editor):
Topic = apps.get_model('core', 'Topic')
NewsSource = apps.get_model('core', 'NewsSource')
Article = apps.get_model('core', 'Article')
Article.objects.filter(dedupe_key='seeded-signal-editorial-launch-story').delete()
NewsSource.objects.filter(slug__in=['techcrunch', 'the-verge', 'wired', 'ars-technica']).delete()
Topic.objects.filter(slug__in=[
'artificial-intelligence',
'startups',
'venture-capital',
'product-cloud',
'hardware',
'security',
]).delete()
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.RunPython(seed_newsroom, unseed_newsroom),
]

View File

@ -1,3 +1,87 @@
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify
# Create your models here.
class Topic(models.Model):
name = models.CharField(max_length=80, unique=True)
slug = models.SlugField(max_length=90, unique=True)
description = models.TextField(blank=True)
accent_color = models.CharField(max_length=7, default="#00c2a8")
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
class NewsSource(models.Model):
name = models.CharField(max_length=120, unique=True)
slug = models.SlugField(max_length=140, unique=True)
site_url = models.URLField(blank=True)
feed_url = models.URLField(unique=True)
description = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
last_synced_at = models.DateTimeField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
class Article(models.Model):
class ArticleKind(models.TextChoices):
RSS = "rss", "RSS import"
ORIGINAL = "original", "Original story"
title = models.CharField(max_length=220)
slug = models.SlugField(max_length=240, unique=True)
excerpt = models.TextField(blank=True)
content = models.TextField(blank=True)
article_kind = models.CharField(max_length=20, choices=ArticleKind.choices, default=ArticleKind.RSS)
topic = models.ForeignKey(Topic, on_delete=models.SET_NULL, null=True, blank=True, related_name='articles')
source = models.ForeignKey(NewsSource, on_delete=models.SET_NULL, null=True, blank=True, related_name='articles')
external_url = models.URLField(blank=True)
image_url = models.URLField(blank=True)
author_name = models.CharField(max_length=120, blank=True)
published_at = models.DateTimeField(default=timezone.now)
dedupe_key = models.CharField(max_length=64, unique=True, blank=True)
is_featured = models.BooleanField(default=False)
is_published = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-published_at', '-created_at']
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if not self.slug:
base_slug = slugify(self.title)[:220] or 'story'
slug = base_slug
suffix = 2
while Article.objects.exclude(pk=self.pk).filter(slug=slug).exists():
slug = f"{base_slug[:210]}-{suffix}"
suffix += 1
self.slug = slug
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse('article_detail', args=[self.slug])

161
core/rss.py Normal file
View File

@ -0,0 +1,161 @@
from __future__ import annotations
import hashlib
import logging
import re
from datetime import timezone as dt_timezone
from datetime import timedelta
from email.utils import parsedate_to_datetime
from html import unescape
from urllib.request import Request, urlopen
import xml.etree.ElementTree as ET
from django.db.models import Q
from django.utils import timezone
from .models import Article, NewsSource, Topic
logger = logging.getLogger(__name__)
HTML_RE = re.compile(r'<[^>]+>')
NAMESPACES = {
'atom': 'http://www.w3.org/2005/Atom',
'media': 'http://search.yahoo.com/mrss/',
'content': 'http://purl.org/rss/1.0/modules/content/',
}
DEFAULT_TOPIC_MAP = {
'ai': 'Artificial Intelligence',
'artificial intelligence': 'Artificial Intelligence',
'startup': 'Startups',
'startups': 'Startups',
'venture': 'Venture Capital',
'funding': 'Venture Capital',
'cloud': 'Product & Cloud',
'saas': 'Product & Cloud',
'product': 'Product & Cloud',
'hardware': 'Hardware',
'chips': 'Hardware',
'security': 'Security',
}
def strip_html(value: str) -> str:
return re.sub(r'\s+', ' ', HTML_RE.sub(' ', unescape(value or ''))).strip()
def text_or_empty(node, path: str) -> str:
found = node.find(path, NAMESPACES)
if found is None:
return ''
return ''.join(found.itertext()).strip()
def pick_topic(*parts: str) -> Topic | None:
combined = ' '.join(part.lower() for part in parts if part)
for keyword, topic_name in DEFAULT_TOPIC_MAP.items():
if keyword in combined:
return Topic.objects.filter(name=topic_name).first()
return Topic.objects.order_by('name').first()
def parse_datetime(value: str):
if not value:
return timezone.now()
try:
parsed = parsedate_to_datetime(value)
if timezone.is_naive(parsed):
parsed = timezone.make_aware(parsed, dt_timezone.utc)
return parsed.astimezone(dt_timezone.utc)
except (TypeError, ValueError, IndexError, OverflowError):
return timezone.now()
def _extract_image(item: ET.Element) -> str:
enclosure = item.find('enclosure')
if enclosure is not None and 'image' in enclosure.attrib.get('type', ''):
return enclosure.attrib.get('url', '')
for path in ['media:content', 'media:thumbnail']:
media = item.find(path, NAMESPACES)
if media is not None:
return media.attrib.get('url', '')
return ''
def _dedupe_key(source: NewsSource, guid: str, link: str, title: str) -> str:
payload = f"{source.pk}|{guid or link or title}".encode('utf-8')
return hashlib.sha256(payload).hexdigest()
def import_feed(source: NewsSource, limit: int = 8) -> int:
request = Request(
source.feed_url,
headers={'User-Agent': 'Mozilla/5.0 FlatlogicNewsroomBot/1.0'},
)
with urlopen(request, timeout=15) as response:
body = response.read()
root = ET.fromstring(body)
channel_items = root.findall('./channel/item')
atom_entries = root.findall('./atom:entry', NAMESPACES)
items = channel_items or atom_entries
created_count = 0
for item in items[:limit]:
title = text_or_empty(item, 'title') or text_or_empty(item, 'atom:title')
link = text_or_empty(item, 'link') or item.attrib.get('href', '')
if not link:
atom_link = item.find('atom:link', NAMESPACES)
if atom_link is not None:
link = atom_link.attrib.get('href', '')
guid = text_or_empty(item, 'guid') or text_or_empty(item, 'atom:id')
excerpt = (
text_or_empty(item, 'description')
or text_or_empty(item, 'atom:summary')
or text_or_empty(item, 'content:encoded')
)
content = text_or_empty(item, 'content:encoded') or text_or_empty(item, 'atom:content') or excerpt
published_raw = (
text_or_empty(item, 'pubDate')
or text_or_empty(item, 'published')
or text_or_empty(item, 'updated')
or text_or_empty(item, 'atom:updated')
)
author_name = text_or_empty(item, 'author') or text_or_empty(item, 'atom:author/atom:name')
category_text = ' '.join(elem.text or '' for elem in item.findall('category'))
dedupe_key = _dedupe_key(source, guid, link, title)
if not title or Article.objects.filter(dedupe_key=dedupe_key).exists():
continue
article = Article(
title=strip_html(title),
excerpt=strip_html(excerpt)[:340],
content=strip_html(content),
article_kind=Article.ArticleKind.RSS,
source=source,
topic=pick_topic(title, excerpt, category_text, source.name),
external_url=link,
image_url=_extract_image(item),
author_name=strip_html(author_name)[:120],
published_at=parse_datetime(published_raw),
dedupe_key=dedupe_key,
is_published=True,
)
article.save()
created_count += 1
source.last_synced_at = timezone.now()
source.save(update_fields=['last_synced_at'])
return created_count
def sync_active_sources(limit: int = 6, stale_minutes: int = 45) -> int:
threshold = timezone.now() - timedelta(minutes=stale_minutes)
sources = NewsSource.objects.filter(is_active=True).filter(
Q(last_synced_at__isnull=True) | Q(last_synced_at__lt=threshold)
)
total_created = 0
for source in sources:
try:
total_created += import_feed(source, limit=limit)
except Exception as exc: # pragma: no cover - graceful failure for live feed parsing
logger.warning('RSS sync failed for %s: %s', source.name, exc)
return total_created

View File

@ -1,25 +1,76 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% block title %}Knowledge Base{% endblock %}</title>
{% if project_description %}
<meta name="description" content="{{ project_description }}">
<meta property="og:description" content="{{ project_description }}">
<meta property="twitter:description" content="{{ project_description }}">
{% endif %}
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ page_title|default:"Signal" }}{% endblock %}</title>
<meta name="description" content="{{ meta_description|default:project_description|default:'Signal is a polished startup and tech newsroom with curated RSS feeds and original editorial publishing.' }}">
<meta name="author" content="Signal Editorial Desk">
{% if project_image_url %}
<meta property="og:image" content="{{ project_image_url }}">
<meta property="twitter:image" content="{{ project_image_url }}">
{% endif %}
{% load static %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
</body>
<div class="page-shell">
<div class="orb orb-a"></div>
<div class="orb orb-b"></div>
<header class="site-header py-3">
<nav class="navbar navbar-expand-lg navbar-dark newsroom-nav">
<div class="container">
<a class="navbar-brand d-flex align-items-center gap-2" href="{% url 'home' %}">
<span class="brand-mark">S</span>
<span class="brand-word">Signal</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
<li class="nav-item"><a class="nav-link" href="{% url 'article_list' %}">Stories</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'article_submit' %}">Publish</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/">Admin</a></li>
<li class="nav-item mt-3 mt-lg-0"><a class="btn btn-signal" href="{% url 'article_submit' %}">Open CMS</a></li>
</ul>
</div>
</div>
</nav>
</header>
<main>
{% if messages %}
<div class="container pt-2">
{% for message in messages %}
<div class="alert alert-success signal-alert mb-0 mt-2" role="alert">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<footer class="site-footer py-5">
<div class="container d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center">
<div>
<p class="footer-brand mb-1">Signal — startup & tech stories with editorial taste.</p>
<p class="footer-copy mb-0">Built for fast scanning, smart curation, and in-house publishing.</p>
</div>
<div class="footer-links d-flex flex-wrap gap-3">
<a href="{% url 'home' %}">Home</a>
<a href="{% url 'article_list' %}">Latest stories</a>
<a href="{% url 'article_submit' %}">Submit a story</a>
<a href="/admin/">Django admin</a>
</div>
</div>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous" defer></script>
<script src="{% static 'js/newsroom.js' %}?v={{ deployment_timestamp }}" defer></script>
</body>
</html>

View File

@ -1,14 +1,76 @@
{% extends 'base.html' %}
{% extends "base.html" %}
{% block title %}{{ article.title }}{% endblock %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<div class="container mt-5">
<h1>{{ article.title }}</h1>
<p class="text-muted">Published on {{ article.created_at|date:"F d, Y" }}</p>
<hr>
<div>
{{ article.content|safe }}
<section class="page-hero compact-hero py-5">
<div class="container">
<div class="article-shell glass-panel">
<div class="story-meta mb-3">
<span>{{ article.topic.name|default:"Tech" }}</span>
<span>{% if article.source %}{{ article.source.name }}{% else %}Signal Original{% endif %}</span>
<span>{{ article.published_at|date:"F d, Y" }}</span>
</div>
<h1 class="article-title">{{ article.title }}</h1>
<p class="article-dek">{{ article.excerpt }}</p>
<div class="article-actions d-flex flex-wrap gap-3 mt-4">
{% if article.external_url %}
<a class="btn btn-signal" href="{{ article.external_url }}" target="_blank" rel="noopener">Read original source</a>
{% endif %}
<a class="btn btn-outline-light" href="{% url 'article_list' %}">Back to stories</a>
</div>
</div>
</div>
</div>
</section>
<section class="section-block pb-5">
<div class="container">
<div class="row g-4">
<div class="col-lg-8">
<article class="glass-panel article-body">
{% if article.article_kind == 'original' %}
<div class="rich-copy">{{ article.content|linebreaks }}</div>
{% else %}
<div class="rich-copy">
{{ article.content|linebreaks }}
{% if article.external_url %}
<p><strong>Source note:</strong> This item was imported from an RSS feed and lightly summarized for scanning. Use the source link above for the full article.</p>
{% endif %}
</div>
{% endif %}
</article>
</div>
<div class="col-lg-4">
<aside class="editorial-sidebar d-grid gap-4">
<div class="glass-panel sidebar-card">
<span class="eyebrow">Story details</span>
<ul class="detail-list mb-0">
<li><strong>Format</strong><span>{{ article.get_article_kind_display }}</span></li>
<li><strong>Author</strong><span>{{ article.author_name|default:"Editorial desk" }}</span></li>
<li><strong>Topic</strong><span>{{ article.topic.name|default:"General tech" }}</span></li>
</ul>
</div>
<div class="glass-panel sidebar-card">
<span class="eyebrow">Need a custom piece?</span>
<h3>Use the CMS to publish your own analysis.</h3>
<a class="btn btn-outline-light w-100" href="{% url 'article_submit' %}">Start writing</a>
</div>
{% if related_articles %}
<div class="glass-panel sidebar-card">
<span class="eyebrow">Related reads</span>
<div class="related-stack d-grid gap-3">
{% for related in related_articles %}
<a class="related-link" href="{{ related.get_absolute_url }}">
<strong>{{ related.title }}</strong>
<span>{{ related.published_at|date:"M d" }} · {{ related.topic.name|default:"Tech" }}</span>
</a>
{% endfor %}
</div>
</div>
{% endif %}
</aside>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<section class="page-hero compact-hero py-5">
<div class="container">
<span class="eyebrow">Signal CMS</span>
<h1 class="display-title small-title">Publish an original story.</h1>
<p class="hero-copy narrow-copy">This first workflow lets editors submit a polished brief, publish it immediately, and have it appear beside imported RSS coverage.</p>
</div>
</section>
<section class="section-block pb-5">
<div class="container">
<div class="row g-4">
<div class="col-lg-7">
<form method="post" class="glass-panel article-form-card">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
{% endif %}
<div class="row g-3">
<div class="col-12">
<label class="form-label" for="{{ form.title.id_for_label }}">Headline</label>
{{ form.title }}
<div class="form-error">{{ form.title.errors }}</div>
</div>
<div class="col-md-6">
<label class="form-label" for="{{ form.topic.id_for_label }}">Topic</label>
{{ form.topic }}
<div class="form-error">{{ form.topic.errors }}</div>
</div>
<div class="col-md-6">
<label class="form-label" for="{{ form.author_name.id_for_label }}">Author</label>
{{ form.author_name }}
<div class="form-error">{{ form.author_name.errors }}</div>
</div>
<div class="col-12">
<label class="form-label" for="{{ form.excerpt.id_for_label }}">Deck / summary</label>
{{ form.excerpt }}
<div class="form-error">{{ form.excerpt.errors }}</div>
</div>
<div class="col-12">
<label class="form-label" for="{{ form.content.id_for_label }}">Story body</label>
{{ form.content }}
<div class="form-error">{{ form.content.errors }}</div>
</div>
<div class="col-12 form-check-wrapper">
<div class="form-check">{{ form.is_featured }} <label class="form-check-label" for="{{ form.is_featured.id_for_label }}">{{ form.is_featured.label }}</label></div>
</div>
<div class="col-12 d-flex flex-wrap gap-3">
<button class="btn btn-signal btn-lg" type="submit">Publish story</button>
<a class="btn btn-outline-light btn-lg" href="/admin/">Manage in admin</a>
</div>
</div>
</form>
</div>
<div class="col-lg-5">
<div class="editorial-sidebar d-grid gap-4">
<div class="glass-panel sidebar-card">
<span class="eyebrow">What this validates</span>
<ul class="detail-list mb-0">
<li><strong>Summary</strong><span>Minimum 20 characters</span></li>
<li><strong>Body</strong><span>Minimum 80 characters</span></li>
<li><strong>Topic</strong><span>Connects to list filters</span></li>
</ul>
</div>
<div class="glass-panel sidebar-card">
<span class="eyebrow">End-to-end flow</span>
<p class="mb-0">Submit → see confirmation → open detail page → find the story in the main listing. That gives you a real MVP slice from CMS input to public consumption.</p>
</div>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<section class="page-hero compact-hero py-5">
<div class="container">
<span class="eyebrow">Signal archive</span>
<h1 class="display-title small-title">Browse the full newsroom.</h1>
<p class="hero-copy narrow-copy">Filter the stream by topic or keyword, then jump into the detail page for the full summary, source context, and related reads.</p>
<form method="get" class="signal-search story-search mt-4">
<div class="row g-3 align-items-end">
<div class="col-md-7 col-lg-6">{{ filter_form.q }}</div>
<div class="col-md-3 col-lg-3">{{ filter_form.topic }}</div>
<div class="col-md-2 col-lg-2 d-grid"><button class="btn btn-signal btn-lg" type="submit">Filter</button></div>
</div>
</form>
</div>
</section>
<section class="section-block pb-5">
<div class="container">
{% if selected_topic %}
<div class="filter-summary mb-4">Showing stories for <strong>{{ selected_topic.name }}</strong>.</div>
{% endif %}
<div class="story-grid story-grid-wide">
{% for story in stories %}
<article class="story-card glass-panel tall-card">
<div class="story-meta mb-3">
<span>{{ story.topic.name|default:"Tech" }}</span>
<span>{{ story.published_at|date:"M d, Y" }}</span>
</div>
<h2 class="h4"><a href="{{ story.get_absolute_url }}">{{ story.title }}</a></h2>
<p>{{ story.excerpt|default:story.content|truncatechars:180 }}</p>
<div class="story-footer mt-auto">
<span>{% if story.source %}{{ story.source.name }}{% else %}Signal Original{% endif %}</span>
<span>{{ story.author_name|default:"Editorial" }}</span>
</div>
</article>
{% empty %}
<div class="glass-panel p-5 text-center w-100">
<h2 class="h4">No matches yet</h2>
<p class="mb-3">Try a different keyword, switch topics, or publish the first original story.</p>
<a class="btn btn-signal" href="{% url 'article_submit' %}">Open CMS</a>
</div>
{% endfor %}
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<section class="page-hero compact-hero py-5">
<div class="container">
<div class="glass-panel success-card text-center mx-auto">
<span class="eyebrow">Published</span>
<h1 class="display-title small-title">Your story is now live.</h1>
<p class="hero-copy narrow-copy mx-auto">“{{ article.title }}” has been added to the newsroom and can now sit alongside RSS-imported coverage.</p>
<div class="d-flex flex-wrap justify-content-center gap-3 mt-4">
<a class="btn btn-signal btn-lg" href="{{ article.get_absolute_url }}">Open detail page</a>
<a class="btn btn-outline-light btn-lg" href="{% url 'article_list' %}">View all stories</a>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -1,145 +1,189 @@
{% extends "base.html" %}
{% block title %}{{ project_name }}{% endblock %}
{% block head %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% {
background-position: 0% 0%;
}
100% {
background-position: 100% 100%;
}
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2.5rem 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
h1 {
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
font-weight: 700;
margin: 0 0 1.2rem;
letter-spacing: -0.02em;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
opacity: 0.92;
}
.loader {
margin: 1.5rem auto;
width: 56px;
height: 56px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.runtime code {
background: rgba(0, 0, 0, 0.25);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
footer {
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
font-size: 0.85rem;
opacity: 0.75;
}
</style>
{% endblock %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<main>
<div class="card">
<h1>Analyzing your requirements and generating your app…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
<section class="hero-section hero-bento-section">
<div class="container position-relative">
<div class="row align-items-center g-5">
<div class="col-xl-7 col-lg-8">
<span class="eyebrow">Future-facing news magazine</span>
<h1 class="display-title">Startup, tech, and general news in a living editorial grid.</h1>
<p class="hero-copy">Signal now mixes startup/tech reporting with broader world, business, policy, and culture coverage—surfaced through dynamic tiles, fast search, RSS ingestion, and your in-house CMS.</p>
<form method="get" action="{% url 'article_list' %}" class="signal-search mt-4">
<div class="row g-3 align-items-end">
<div class="col-md-7">
<label class="form-label small text-uppercase text-secondary-emphasis" for="id_q">Search</label>
{{ filter_form.q }}
</div>
<div class="col-md-3">
<label class="form-label small text-uppercase text-secondary-emphasis" for="id_topic">Topic</label>
{{ filter_form.topic }}
</div>
<div class="col-md-2 d-grid">
<button class="btn btn-signal btn-lg" type="submit">Explore</button>
</div>
</div>
</form>
<div class="hero-metrics row row-cols-2 row-cols-md-4 g-3 mt-4">
<div class="col"><div class="metric-card"><span>{{ story_count }}</span><small>Published stories</small></div></div>
<div class="col"><div class="metric-card"><span>{{ topics|length }}</span><small>Topic lanes</small></div></div>
<div class="col"><div class="metric-card"><span>{{ sources|length }}</span><small>Live feeds</small></div></div>
<div class="col"><div class="metric-card"><span>24/7</span><small>Editorial motion</small></div></div>
</div>
</div>
<div class="col-xl-5 col-lg-4">
<div class="hero-panel glass-panel signal-deck-panel drift-card" data-drift="14">
<div class="signal-deck-topline">
<span class="panel-kicker">Front page brief</span>
{% if last_updated %}<span class="deck-timestamp">Updated {{ last_updated.published_at|date:"M d" }}</span>{% endif %}
</div>
{% if featured_story %}
<a class="feature-link" href="{{ featured_story.get_absolute_url }}">
<h2>{{ featured_story.title }}</h2>
<p>{{ featured_story.excerpt|default:featured_story.content|truncatechars:155 }}</p>
</a>
<div class="story-meta">
<span>{{ featured_story.topic.name|default:"News" }}</span>
<span>{% if featured_story.source %}{{ featured_story.source.name }}{% else %}Signal Desk{% endif %}</span>
<span>{{ featured_story.published_at|date:"M d, Y" }}</span>
</div>
<div class="deck-orbits" aria-hidden="true">
<span class="deck-orbit orbit-one"></span>
<span class="deck-orbit orbit-two"></span>
<span class="deck-orbit orbit-three"></span>
</div>
{% else %}
<h2>No stories yet</h2>
<p>Connect RSS feeds or publish an original story to bring this front page to life.</p>
<a class="btn btn-signal mt-3" href="{% url 'article_submit' %}">Publish the first story</a>
{% endif %}
</div>
</div>
</div>
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
<p class="runtime">
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
</p>
</div>
</main>
<footer>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
</footer>
{% endblock %}
</section>
<section class="section-block py-4">
<div class="container">
<div class="section-heading d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-3 mb-4">
<div>
<span class="eyebrow">Topic radar</span>
<h2>Jump between focused beats and broader news lanes.</h2>
</div>
<a class="text-link" href="{% url 'article_list' %}">See all stories</a>
</div>
<div class="topic-rail topic-rail-wide">
{% for topic in topics %}
<a href="{% url 'article_list' %}?topic={{ topic.slug }}" class="topic-pill" style="--topic-accent: {{ topic.accent_color|default:'#00c2a8' }};">
<span class="topic-dot"></span>
{{ topic.name }}
</a>
{% empty %}
<p class="empty-copy mb-0">Create topics in the admin to organize the newsroom.</p>
{% endfor %}
</div>
</div>
</section>
<section class="section-block pb-5">
<div class="container">
<div class="section-heading d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-3 mb-4">
<div>
<span class="eyebrow">Bento front page</span>
<h2>Unbalanced tiles that feel like they are gently in motion.</h2>
</div>
<p class="filter-summary mb-0">A mixed editorial surface for RSS imports, original pieces, and source modules.</p>
</div>
<div class="bento-grid" id="news-bento-grid">
{% if featured_story %}
<article class="glass-panel bento-card bento-feature drift-card" data-drift="18">
<div class="bento-card-copy">
<div class="story-meta mb-3">
<span>{{ featured_story.topic.name|default:"News" }}</span>
<span>{% if featured_story.source %}{{ featured_story.source.name }}{% else %}Signal Original{% endif %}</span>
</div>
<span class="bento-label">Featured signal</span>
<h3><a href="{{ featured_story.get_absolute_url }}">{{ featured_story.title }}</a></h3>
<p>{{ featured_story.excerpt|default:featured_story.content|truncatechars:200 }}</p>
<div class="story-footer mt-auto">
<span>{{ featured_story.published_at|date:"M d, Y" }}</span>
<span>{{ featured_story.author_name|default:"Editorial desk" }}</span>
</div>
</div>
<div class="bento-visual">
{% if featured_story.image_url %}
<img src="{{ featured_story.image_url }}" alt="{{ featured_story.title }}" width="960" height="720">
{% else %}
<div class="shape-stack" aria-hidden="true">
<span class="shape cube"></span>
<span class="shape sphere"></span>
<span class="shape ring"></span>
</div>
{% endif %}
</div>
</article>
{% endif %}
{% for story in spotlight_stories %}
<article class="glass-panel bento-card {% cycle 'bento-wide' 'bento-tall' 'bento-compact' 'bento-compact accent-secondary' 'bento-wide accent-primary' 'bento-tall accent-secondary' %} drift-card" data-drift="{{ forloop.counter|add:8 }}">
<div class="bento-card-copy">
<div class="story-meta mb-3">
<span>{{ story.topic.name|default:"News" }}</span>
<span>{% if story.source %}{{ story.source.name }}{% else %}Signal Original{% endif %}</span>
</div>
<h3><a href="{{ story.get_absolute_url }}">{{ story.title }}</a></h3>
<p>{{ story.excerpt|default:story.content|truncatechars:125 }}</p>
<div class="story-footer mt-auto">
<span>{{ story.published_at|date:"M d" }}</span>
<span>{{ story.author_name|default:"Editorial" }}</span>
</div>
</div>
</article>
{% empty %}
<div class="glass-panel p-4">
<h3 class="h4">No feed items yet</h3>
<p class="mb-0">The grid is ready. Add RSS sources in admin and the next refresh will populate the tiles.</p>
</div>
{% endfor %}
<aside class="glass-panel bento-card bento-sidebar drift-card" data-drift="10">
<span class="eyebrow">Live sources</span>
<ul class="source-list mb-0">
{% for source in sources %}
<li>
<strong>{{ source.name }}</strong>
<span>{{ source.description|default:"RSS source connected" }}</span>
</li>
{% empty %}
<li><strong>No sources configured.</strong><span>Add feed URLs in Django admin.</span></li>
{% endfor %}
</ul>
</aside>
<aside class="glass-panel bento-card bento-editor drift-card" data-drift="12">
<span class="eyebrow">CMS workflow</span>
<h3>Publish an original analysis beside imported headlines.</h3>
<p>Use the editor flow for founder notes, explainers, newsletters, or daily front-page curation.</p>
<div class="d-flex flex-wrap gap-3 mt-auto">
<a class="btn btn-signal" href="{% url 'article_submit' %}">Write a story</a>
<a class="btn btn-outline-light" href="/admin/">Open admin</a>
</div>
</aside>
{% if original_story %}
<article class="glass-panel bento-card bento-original accent-primary drift-card" data-drift="16">
<span class="eyebrow">Original feature</span>
<h3><a href="{{ original_story.get_absolute_url }}">{{ original_story.title }}</a></h3>
<p>{{ original_story.excerpt|truncatechars:145 }}</p>
<div class="story-footer mt-auto">
<span>{{ original_story.published_at|date:"M d" }}</span>
<span>{{ original_story.author_name|default:"Signal Desk" }}</span>
</div>
</article>
{% endif %}
</div>
</div>
</section>
{% endblock %}

View File

@ -1,3 +1,32 @@
from django.test import TestCase
from django.urls import reverse
# Create your tests here.
from .models import Article, Topic
class NewsroomFlowTests(TestCase):
def setUp(self):
self.topic = Topic.objects.create(name='Artificial Intelligence', slug='artificial-intelligence', accent_color='#00c2a8')
def test_home_page_renders(self):
response = self.client.get(reverse('home'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Signal')
def test_submit_flow_creates_original_article(self):
response = self.client.post(
reverse('article_submit'),
{
'title': 'Why startup teams need faster AI evaluation loops',
'topic': self.topic.pk,
'author_name': 'Editorial Desk',
'excerpt': 'A crisp summary explaining why the speed of model testing is now a product advantage.',
'content': 'This is a long enough editorial body to satisfy validation. ' * 4,
'is_featured': 'on',
},
follow=True,
)
self.assertEqual(response.status_code, 200)
article = Article.objects.get(title='Why startup teams need faster AI evaluation loops')
self.assertContains(response, 'Your story is now live.')
self.assertEqual(article.article_kind, Article.ArticleKind.ORIGINAL)

View File

@ -1,7 +1,11 @@
from django.urls import path
from .views import home
from .views import article_detail, article_list, article_submit, article_submit_success, home
urlpatterns = [
path("", home, name="home"),
path('', home, name='home'),
path('stories/', article_list, name='article_list'),
path('stories/<slug:slug>/', article_detail, name='article_detail'),
path('editor/submit/', article_submit, name='article_submit'),
path('editor/success/<slug:slug>/', article_submit_success, name='article_submit_success'),
]

View File

@ -1,25 +1,128 @@
import os
import platform
from django import get_version as django_version
from django.shortcuts import render
import hashlib
from django.contrib import messages
from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from .forms import ArticleFilterForm, OriginalArticleForm
from .models import Article, NewsSource, Topic
from .rss import sync_active_sources
def _published_articles():
return Article.objects.filter(is_published=True).select_related('topic', 'source')
def _build_filter_form(data=None):
return ArticleFilterForm(data or None)
def home(request):
"""Render the landing screen with loader and environment details."""
host_name = request.get_host().lower()
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
now = timezone.now()
sync_active_sources(limit=6)
filter_form = _build_filter_form(request.GET or None)
stories = _published_articles()
if filter_form.is_valid():
query = filter_form.cleaned_data.get('q')
topic_slug = filter_form.cleaned_data.get('topic')
if query:
stories = stories.filter(
Q(title__icontains=query)
| Q(excerpt__icontains=query)
| Q(content__icontains=query)
| Q(author_name__icontains=query)
)
if topic_slug:
stories = stories.filter(topic__slug=topic_slug)
featured_story = stories.filter(is_featured=True).first() or stories.first()
spotlight_stories = stories.exclude(pk=getattr(featured_story, 'pk', None))[:6]
original_story = _published_articles().filter(article_kind=Article.ArticleKind.ORIGINAL).first()
context = {
"project_name": "New Style",
"agent_brand": agent_brand,
"django_version": django_version(),
"python_version": platform.python_version(),
"current_time": now,
"host_name": host_name,
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
'page_title': 'Signal / Startup & Tech Newsroom',
'meta_description': 'A polished startup and tech news magazine with RSS aggregation, editorial curation, and a fast CMS workflow.',
'filter_form': filter_form,
'featured_story': featured_story,
'spotlight_stories': spotlight_stories,
'topics': Topic.objects.order_by('name'),
'sources': NewsSource.objects.filter(is_active=True).order_by('name')[:6],
'story_count': stories.count(),
'original_story': original_story,
'last_updated': _published_articles().first(),
}
return render(request, "core/index.html", context)
return render(request, 'core/index.html', context)
def article_list(request):
sync_active_sources(limit=4)
filter_form = _build_filter_form(request.GET or None)
stories = _published_articles()
selected_topic = None
if filter_form.is_valid():
query = filter_form.cleaned_data.get('q')
topic_slug = filter_form.cleaned_data.get('topic')
if query:
stories = stories.filter(
Q(title__icontains=query)
| Q(excerpt__icontains=query)
| Q(content__icontains=query)
| Q(author_name__icontains=query)
)
if topic_slug:
stories = stories.filter(topic__slug=topic_slug)
selected_topic = Topic.objects.filter(slug=topic_slug).first()
context = {
'page_title': 'All Stories / Signal',
'meta_description': 'Browse curated startup and tech stories by topic, recency, and editorial origin.',
'filter_form': filter_form,
'stories': stories[:24],
'selected_topic': selected_topic,
'topics': Topic.objects.order_by('name'),
}
return render(request, 'core/article_list.html', context)
def article_detail(request, slug):
article = get_object_or_404(_published_articles(), slug=slug)
related_articles = _published_articles().filter(topic=article.topic).exclude(pk=article.pk)[:3]
context = {
'page_title': f'{article.title} / Signal',
'meta_description': article.excerpt or 'Read the latest curated startup and tech story on Signal.',
'article': article,
'related_articles': related_articles,
}
return render(request, 'core/article_detail.html', context)
def article_submit(request):
if request.method == 'POST':
form = OriginalArticleForm(request.POST)
if form.is_valid():
article = form.save(commit=False)
article.article_kind = Article.ArticleKind.ORIGINAL
article.is_published = True
article.published_at = timezone.now()
article.dedupe_key = hashlib.sha256(f"original|{timezone.now().isoformat()}|{article.title}".encode('utf-8')).hexdigest()
article.save()
messages.success(request, 'Your editorial story is live in the newsroom.')
return redirect('article_submit_success', slug=article.slug)
else:
form = OriginalArticleForm()
context = {
'page_title': 'Publish a Story / Signal CMS',
'meta_description': 'Draft an original startup or tech story and publish it into the Signal magazine flow.',
'form': form,
}
return render(request, 'core/article_form.html', context)
def article_submit_success(request, slug):
article = get_object_or_404(Article, slug=slug, article_kind=Article.ArticleKind.ORIGINAL)
context = {
'page_title': 'Story Published / Signal CMS',
'meta_description': 'Your original story has been published and added to the curated tech newsroom.',
'article': article,
}
return render(request, 'core/article_submit_success.html', context)

View File

@ -1,4 +1,891 @@
/* Custom styles for the application */
body {
font-family: system-ui, -apple-system, sans-serif;
/* Signal newsroom custom styles */
:root {
--signal-bg: #08111f;
--signal-surface: rgba(10, 22, 38, 0.78);
--signal-surface-strong: #0f1c30;
--signal-border: rgba(152, 238, 217, 0.18);
--signal-primary: #00c2a8;
--signal-secondary: #ff6b4a;
--signal-accent: #7df9d7;
--signal-text: #f5f7fb;
--signal-muted: #a9b8ca;
--signal-cream: #eef6ff;
--signal-shadow: 0 24px 80px rgba(0, 0, 0, 0.34);
--bs-body-bg: var(--signal-bg);
--bs-body-color: var(--signal-text);
--bs-link-color: var(--signal-cream);
--bs-link-hover-color: var(--signal-accent);
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
font-family: 'Inter', system-ui, sans-serif;
background:
radial-gradient(circle at top left, rgba(0, 194, 168, 0.22), transparent 32%),
radial-gradient(circle at 85% 10%, rgba(255, 107, 74, 0.18), transparent 26%),
linear-gradient(135deg, #07101d 0%, #0c1628 48%, #09131f 100%);
color: var(--signal-text);
}
h1,
h2,
h3,
h4,
.navbar-brand,
.eyebrow,
.btn {
font-family: 'Space Grotesk', 'Inter', sans-serif;
}
img {
max-width: 100%;
height: auto;
display: block;
}
.page-shell {
position: relative;
overflow: hidden;
}
.orb {
position: fixed;
border-radius: 999px;
filter: blur(18px);
opacity: 0.5;
pointer-events: none;
z-index: 0;
animation: floaty 10s ease-in-out infinite;
}
.orb-a {
width: 16rem;
height: 16rem;
background: rgba(0, 194, 168, 0.18);
top: 6rem;
right: -3rem;
}
.orb-b {
width: 13rem;
height: 13rem;
background: rgba(255, 107, 74, 0.14);
left: -2rem;
bottom: 10rem;
animation-delay: -3s;
}
@keyframes floaty {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(0, -18px, 0); }
}
.site-header,
.site-footer,
.hero-section,
.section-block,
.page-hero {
position: relative;
z-index: 1;
}
.newsroom-nav {
backdrop-filter: blur(16px);
}
.brand-mark {
width: 2.3rem;
height: 2.3rem;
border-radius: 0.8rem;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--signal-primary), var(--signal-secondary));
color: #08111f;
font-weight: 700;
box-shadow: 0 12px 24px rgba(0, 194, 168, 0.18);
}
.brand-word {
letter-spacing: -0.05em;
font-size: 1.3rem;
font-weight: 700;
}
.nav-link {
color: rgba(245, 247, 251, 0.76);
font-weight: 500;
}
.nav-link:hover,
.nav-link:focus {
color: var(--signal-cream);
}
.btn-signal {
border: none;
color: #08111f;
background: linear-gradient(135deg, var(--signal-primary), #57f5cf);
box-shadow: 0 18px 32px rgba(0, 194, 168, 0.26);
padding: 0.9rem 1.3rem;
border-radius: 999px;
font-weight: 700;
}
.btn-signal:hover,
.btn-signal:focus {
color: #08111f;
transform: translateY(-1px);
background: linear-gradient(135deg, #20d6ba, #88ffe2);
}
.btn-outline-light {
border-radius: 999px;
border-color: rgba(245, 247, 251, 0.45);
}
.hero-section,
.page-hero {
padding: 4.5rem 0 2rem;
}
.compact-hero {
padding-top: 2.5rem;
}
.display-title {
font-size: clamp(2.9rem, 5vw, 5.6rem);
line-height: 0.95;
letter-spacing: -0.06em;
margin-bottom: 1.4rem;
max-width: 12ch;
}
.small-title {
max-width: 14ch;
font-size: clamp(2.3rem, 4vw, 4.2rem);
}
.hero-copy,
.article-dek,
.rich-copy,
.footer-copy,
.empty-copy,
.filter-summary {
color: var(--signal-muted);
font-size: 1.05rem;
line-height: 1.75;
}
.narrow-copy {
max-width: 44rem;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.84rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--signal-accent);
margin-bottom: 1rem;
}
.eyebrow::before {
content: '';
width: 2.2rem;
height: 1px;
background: currentColor;
}
.glass-panel {
background: linear-gradient(180deg, rgba(14, 28, 46, 0.82), rgba(9, 17, 31, 0.92));
border: 1px solid var(--signal-border);
box-shadow: var(--signal-shadow);
border-radius: 1.75rem;
backdrop-filter: blur(20px);
}
.hero-panel,
.article-shell,
.article-form-card,
.success-card {
padding: 2rem;
}
.panel-kicker {
color: var(--signal-accent);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.75rem;
}
.feature-link,
.feature-link:hover {
color: var(--signal-text);
text-decoration: none;
}
.feature-link h2,
.section-heading h2,
.story-card h3,
.sidebar-card h3,
.article-title {
letter-spacing: -0.04em;
}
.feature-link h2 {
font-size: clamp(1.8rem, 2vw, 2.6rem);
margin-bottom: 1rem;
}
.story-meta,
.story-footer,
.detail-list li,
.source-list li,
.related-link span {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: var(--signal-muted);
font-size: 0.84rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.story-meta span,
.story-footer span {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.story-meta span::before,
.story-footer span::before {
content: '•';
color: var(--signal-primary);
}
.story-meta span:first-child::before,
.story-footer span:first-child::before {
display: none;
}
.story-visual {
margin-top: 1.5rem;
border-radius: 1.4rem;
overflow: hidden;
border: 1px solid rgba(245, 247, 251, 0.08);
}
.story-visual img {
width: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
}
.shape-stack {
min-height: 220px;
display: grid;
place-items: center;
position: relative;
}
.shape {
position: absolute;
display: block;
}
.shape.cube {
width: 8rem;
height: 8rem;
border-radius: 2rem;
background: linear-gradient(135deg, rgba(0, 194, 168, 0.95), rgba(5, 28, 50, 0.7));
transform: rotate(18deg);
}
.shape.sphere {
width: 5rem;
height: 5rem;
background: radial-gradient(circle at 30% 30%, #fff, rgba(255, 107, 74, 0.9));
border-radius: 50%;
top: 1rem;
right: 4rem;
}
.shape.ring {
width: 11rem;
height: 11rem;
border-radius: 50%;
border: 1.2rem solid rgba(125, 249, 215, 0.2);
left: 2rem;
bottom: 0;
}
.signal-search {
background: rgba(8, 17, 31, 0.66);
padding: 1rem;
border-radius: 1.5rem;
border: 1px solid rgba(125, 249, 215, 0.13);
}
.form-control,
.form-select {
background-color: rgba(255, 255, 255, 0.04);
border-color: rgba(169, 184, 202, 0.24);
color: var(--signal-text);
border-radius: 1rem;
min-height: 3.3rem;
}
.form-control:focus,
.form-select:focus,
.form-check-input:focus,
.btn:focus {
box-shadow: 0 0 0 0.22rem rgba(0, 194, 168, 0.22);
border-color: rgba(0, 194, 168, 0.72);
}
.form-control::placeholder {
color: rgba(169, 184, 202, 0.8);
}
.form-select option {
color: #08111f;
}
.hero-metrics .metric-card {
border: 1px solid rgba(125, 249, 215, 0.12);
border-radius: 1.2rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.03);
min-height: 100%;
}
.metric-card span {
display: block;
font-family: 'Space Grotesk', sans-serif;
font-size: 1.6rem;
margin-bottom: 0.2rem;
}
.metric-card small {
color: var(--signal-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.section-block {
padding-top: 1rem;
padding-bottom: 2rem;
}
.section-heading h2 {
font-size: clamp(1.7rem, 3vw, 2.8rem);
margin: 0;
}
.text-link {
color: var(--signal-accent);
text-decoration: none;
}
.topic-rail {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.topic-pill {
display: inline-flex;
align-items: center;
gap: 0.7rem;
padding: 0.95rem 1.25rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(169, 184, 202, 0.16);
text-decoration: none;
color: var(--signal-text);
transition: transform 0.2s ease, border-color 0.2s ease;
}
.topic-pill:hover,
.topic-pill:focus {
transform: translateY(-2px);
border-color: rgba(0, 194, 168, 0.45);
color: var(--signal-cream);
}
.topic-dot {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
box-shadow: 0 0 18px currentColor;
}
.story-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.2rem;
}
.story-grid-wide {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.story-card {
min-height: 100%;
transition: transform 0.22s ease, border-color 0.22s ease;
}
.story-card:hover,
.story-card:focus-within {
transform: translateY(-4px);
border-color: rgba(0, 194, 168, 0.4);
}
.story-card-inner,
.story-card,
.sidebar-card,
.article-body {
padding: 1.4rem;
}
.story-card h3,
.story-card h2,
.sidebar-card h3 {
font-size: 1.35rem;
margin-bottom: 0.8rem;
}
.story-card a,
.related-link,
.footer-links a {
color: var(--signal-text);
text-decoration: none;
}
.story-card p,
.sidebar-card p,
.source-list li span,
.related-link span {
color: var(--signal-muted);
line-height: 1.7;
}
.story-footer {
margin-top: auto;
}
.tall-card {
display: flex;
flex-direction: column;
}
.editorial-sidebar,
.article-form-card .row {
height: 100%;
}
.sidebar-card {
border-radius: 1.5rem;
}
.source-list,
.detail-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 1rem;
}
.source-list li,
.detail-list li {
display: grid;
gap: 0.35rem;
text-transform: none;
letter-spacing: normal;
}
.related-link {
display: grid;
gap: 0.35rem;
padding: 0.25rem 0;
}
.article-title {
font-size: clamp(2.2rem, 3vw, 3.4rem);
line-height: 1;
}
.article-body {
padding: 2rem;
}
.rich-copy p {
margin-bottom: 1.15rem;
}
.form-error ul {
list-style: none;
padding: 0;
margin: 0.5rem 0 0;
color: #ffac97;
}
.form-check-wrapper {
padding-top: 0.35rem;
}
.form-check-input {
background-color: rgba(255, 255, 255, 0.05);
border-color: rgba(169, 184, 202, 0.32);
}
.form-check-label {
margin-left: 0.4rem;
}
.success-card {
max-width: 50rem;
}
.signal-alert {
border: 1px solid rgba(0, 194, 168, 0.32);
background: rgba(0, 194, 168, 0.13);
color: var(--signal-text);
border-radius: 1rem;
}
.site-footer {
border-top: 1px solid rgba(169, 184, 202, 0.12);
margin-top: 2rem;
}
.footer-brand {
font-weight: 600;
}
.footer-links a {
color: rgba(245, 247, 251, 0.72);
}
.footer-links a:hover,
.footer-links a:focus {
color: var(--signal-accent);
}
@media (max-width: 991.98px) {
.display-title,
.small-title {
max-width: 100%;
}
.story-grid,
.story-grid-wide {
grid-template-columns: 1fr;
}
.hero-section {
padding-top: 2.5rem;
}
}
@media (max-width: 575.98px) {
.hero-panel,
.article-shell,
.article-form-card,
.success-card,
.story-card,
.sidebar-card,
.article-body {
padding: 1.2rem;
border-radius: 1.25rem;
}
.signal-search {
padding: 0.85rem;
}
}
.hero-bento-section {
padding-bottom: 1rem;
}
.signal-deck-panel {
min-height: 100%;
position: relative;
overflow: hidden;
}
.signal-deck-topline {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
}
.deck-timestamp,
.bento-label {
font-size: 0.76rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--signal-accent);
}
.deck-orbits {
position: relative;
min-height: 12rem;
margin-top: 1.5rem;
}
.deck-orbit {
position: absolute;
border-radius: 999px;
border: 1px solid rgba(125, 249, 215, 0.2);
background: radial-gradient(circle at 30% 30%, rgba(125, 249, 215, 0.18), rgba(8, 17, 31, 0.02));
animation: deckPulse 9s ease-in-out infinite;
}
.orbit-one {
width: 9rem;
height: 9rem;
top: 0;
left: 1rem;
}
.orbit-two {
width: 7rem;
height: 7rem;
right: 1.5rem;
top: 2rem;
animation-delay: -3s;
}
.orbit-three {
width: 4.5rem;
height: 4.5rem;
left: 45%;
bottom: 0.8rem;
animation-delay: -6s;
}
@keyframes deckPulse {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(0, -12px, 0) scale(1.04); }
}
.topic-rail-wide .topic-pill {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03));
}
.topic-rail-wide .topic-dot {
background: var(--topic-accent, var(--signal-primary));
color: var(--topic-accent, var(--signal-primary));
}
.bento-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 1.15rem;
align-items: stretch;
grid-auto-rows: minmax(88px, auto);
}
.bento-card {
position: relative;
overflow: hidden;
min-height: 100%;
display: flex;
flex-direction: column;
transition: transform 0.28s ease, border-color 0.28s ease, box-shadow 0.28s ease;
}
.bento-card::before {
content: '';
position: absolute;
inset: auto -10% -35% auto;
width: 11rem;
height: 11rem;
border-radius: 999px;
background: radial-gradient(circle, rgba(0, 194, 168, 0.18), transparent 70%);
pointer-events: none;
}
.bento-card.accent-secondary::before {
background: radial-gradient(circle, rgba(255, 107, 74, 0.18), transparent 70%);
}
.bento-card.accent-primary::before {
background: radial-gradient(circle, rgba(125, 249, 215, 0.22), transparent 70%);
}
.bento-card:hover,
.bento-card:focus-within {
transform: translateY(-6px);
border-color: rgba(125, 249, 215, 0.34);
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.42);
}
.bento-card-copy,
.bento-sidebar,
.bento-editor,
.bento-original {
padding: 1.45rem;
position: relative;
z-index: 1;
height: 100%;
}
.bento-feature {
grid-column: span 7;
grid-row: span 4;
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(220px, 0.8fr);
}
.bento-feature .bento-card-copy {
display: flex;
flex-direction: column;
}
.bento-feature .bento-visual {
min-height: 100%;
position: relative;
padding: 1.45rem 1.45rem 1.45rem 0;
display: flex;
align-items: stretch;
}
.bento-feature .bento-visual img,
.bento-feature .shape-stack {
width: 100%;
border-radius: 1.35rem;
overflow: hidden;
min-height: 100%;
}
.bento-feature .bento-visual img {
aspect-ratio: 4 / 5;
object-fit: cover;
}
.bento-wide {
grid-column: span 5;
grid-row: span 2;
}
.bento-tall {
grid-column: span 4;
grid-row: span 3;
}
.bento-compact {
grid-column: span 3;
grid-row: span 2;
}
.bento-sidebar {
grid-column: span 4;
grid-row: span 3;
}
.bento-editor {
grid-column: span 4;
grid-row: span 3;
}
.bento-original {
grid-column: span 4;
grid-row: span 2;
}
.bento-grid h3 {
font-size: clamp(1.2rem, 2vw, 2rem);
line-height: 1.05;
letter-spacing: -0.04em;
margin-bottom: 0.8rem;
}
.bento-grid p {
color: var(--signal-muted);
line-height: 1.7;
}
.drift-card {
will-change: transform;
}
@media (max-width: 1199.98px) {
.bento-feature {
grid-column: span 12;
}
.bento-wide,
.bento-sidebar,
.bento-editor,
.bento-original {
grid-column: span 6;
}
.bento-tall,
.bento-compact {
grid-column: span 6;
grid-row: span 2;
}
}
@media (max-width: 991.98px) {
.bento-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.bento-feature,
.bento-wide,
.bento-tall,
.bento-compact,
.bento-sidebar,
.bento-editor,
.bento-original {
grid-column: span 2;
grid-row: auto;
}
.bento-feature {
grid-template-columns: 1fr;
}
.bento-feature .bento-visual {
padding: 0 1.45rem 1.45rem;
}
}
@media (max-width: 575.98px) {
.signal-deck-topline {
flex-direction: column;
align-items: flex-start;
}
.bento-grid {
grid-template-columns: 1fr;
}
.bento-feature,
.bento-wide,
.bento-tall,
.bento-compact,
.bento-sidebar,
.bento-editor,
.bento-original {
grid-column: span 1;
}
}

33
static/js/newsroom.js Normal file
View File

@ -0,0 +1,33 @@
document.addEventListener('DOMContentLoaded', () => {
const cards = Array.from(document.querySelectorAll('[data-drift]'));
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!cards.length || reduceMotion) {
return;
}
const updateCard = (card, xRatio, yRatio) => {
const depth = Number(card.dataset.drift || 10);
const offsetX = xRatio * depth;
const offsetY = yRatio * depth;
card.style.transform = `translate3d(${offsetX.toFixed(2)}px, ${offsetY.toFixed(2)}px, 0)`;
};
const resetCard = (card) => {
card.style.transform = '';
};
cards.forEach((card, index) => {
card.style.animation = `floaty ${9 + (index % 4)}s ease-in-out ${index * -0.8}s infinite`;
});
window.addEventListener('mousemove', (event) => {
const xRatio = (event.clientX / window.innerWidth - 0.5) * 1.2;
const yRatio = (event.clientY / window.innerHeight - 0.5) * 1.2;
cards.forEach((card) => updateCard(card, xRatio, yRatio));
}, { passive: true });
window.addEventListener('mouseleave', () => {
cards.forEach(resetCard);
});
});

View File

@ -1,21 +1,891 @@
/* Signal newsroom custom styles */
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
--signal-bg: #08111f;
--signal-surface: rgba(10, 22, 38, 0.78);
--signal-surface-strong: #0f1c30;
--signal-border: rgba(152, 238, 217, 0.18);
--signal-primary: #00c2a8;
--signal-secondary: #ff6b4a;
--signal-accent: #7df9d7;
--signal-text: #f5f7fb;
--signal-muted: #a9b8ca;
--signal-cream: #eef6ff;
--signal-shadow: 0 24px 80px rgba(0, 0, 0, 0.34);
--bs-body-bg: var(--signal-bg);
--bs-body-color: var(--signal-text);
--bs-link-color: var(--signal-cream);
--bs-link-hover-color: var(--signal-accent);
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
font-family: 'Inter', system-ui, sans-serif;
background:
radial-gradient(circle at top left, rgba(0, 194, 168, 0.22), transparent 32%),
radial-gradient(circle at 85% 10%, rgba(255, 107, 74, 0.18), transparent 26%),
linear-gradient(135deg, #07101d 0%, #0c1628 48%, #09131f 100%);
color: var(--signal-text);
}
h1,
h2,
h3,
h4,
.navbar-brand,
.eyebrow,
.btn {
font-family: 'Space Grotesk', 'Inter', sans-serif;
}
img {
max-width: 100%;
height: auto;
display: block;
}
.page-shell {
position: relative;
overflow: hidden;
}
.orb {
position: fixed;
border-radius: 999px;
filter: blur(18px);
opacity: 0.5;
pointer-events: none;
z-index: 0;
animation: floaty 10s ease-in-out infinite;
}
.orb-a {
width: 16rem;
height: 16rem;
background: rgba(0, 194, 168, 0.18);
top: 6rem;
right: -3rem;
}
.orb-b {
width: 13rem;
height: 13rem;
background: rgba(255, 107, 74, 0.14);
left: -2rem;
bottom: 10rem;
animation-delay: -3s;
}
@keyframes floaty {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(0, -18px, 0); }
}
.site-header,
.site-footer,
.hero-section,
.section-block,
.page-hero {
position: relative;
z-index: 1;
}
.newsroom-nav {
backdrop-filter: blur(16px);
}
.brand-mark {
width: 2.3rem;
height: 2.3rem;
border-radius: 0.8rem;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--signal-primary), var(--signal-secondary));
color: #08111f;
font-weight: 700;
box-shadow: 0 12px 24px rgba(0, 194, 168, 0.18);
}
.brand-word {
letter-spacing: -0.05em;
font-size: 1.3rem;
font-weight: 700;
}
.nav-link {
color: rgba(245, 247, 251, 0.76);
font-weight: 500;
}
.nav-link:hover,
.nav-link:focus {
color: var(--signal-cream);
}
.btn-signal {
border: none;
color: #08111f;
background: linear-gradient(135deg, var(--signal-primary), #57f5cf);
box-shadow: 0 18px 32px rgba(0, 194, 168, 0.26);
padding: 0.9rem 1.3rem;
border-radius: 999px;
font-weight: 700;
}
.btn-signal:hover,
.btn-signal:focus {
color: #08111f;
transform: translateY(-1px);
background: linear-gradient(135deg, #20d6ba, #88ffe2);
}
.btn-outline-light {
border-radius: 999px;
border-color: rgba(245, 247, 251, 0.45);
}
.hero-section,
.page-hero {
padding: 4.5rem 0 2rem;
}
.compact-hero {
padding-top: 2.5rem;
}
.display-title {
font-size: clamp(2.9rem, 5vw, 5.6rem);
line-height: 0.95;
letter-spacing: -0.06em;
margin-bottom: 1.4rem;
max-width: 12ch;
}
.small-title {
max-width: 14ch;
font-size: clamp(2.3rem, 4vw, 4.2rem);
}
.hero-copy,
.article-dek,
.rich-copy,
.footer-copy,
.empty-copy,
.filter-summary {
color: var(--signal-muted);
font-size: 1.05rem;
line-height: 1.75;
}
.narrow-copy {
max-width: 44rem;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.84rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--signal-accent);
margin-bottom: 1rem;
}
.eyebrow::before {
content: '';
width: 2.2rem;
height: 1px;
background: currentColor;
}
.glass-panel {
background: linear-gradient(180deg, rgba(14, 28, 46, 0.82), rgba(9, 17, 31, 0.92));
border: 1px solid var(--signal-border);
box-shadow: var(--signal-shadow);
border-radius: 1.75rem;
backdrop-filter: blur(20px);
}
.hero-panel,
.article-shell,
.article-form-card,
.success-card {
padding: 2rem;
}
.panel-kicker {
color: var(--signal-accent);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.75rem;
}
.feature-link,
.feature-link:hover {
color: var(--signal-text);
text-decoration: none;
}
.feature-link h2,
.section-heading h2,
.story-card h3,
.sidebar-card h3,
.article-title {
letter-spacing: -0.04em;
}
.feature-link h2 {
font-size: clamp(1.8rem, 2vw, 2.6rem);
margin-bottom: 1rem;
}
.story-meta,
.story-footer,
.detail-list li,
.source-list li,
.related-link span {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: var(--signal-muted);
font-size: 0.84rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.story-meta span,
.story-footer span {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.story-meta span::before,
.story-footer span::before {
content: '•';
color: var(--signal-primary);
}
.story-meta span:first-child::before,
.story-footer span:first-child::before {
display: none;
}
.story-visual {
margin-top: 1.5rem;
border-radius: 1.4rem;
overflow: hidden;
border: 1px solid rgba(245, 247, 251, 0.08);
}
.story-visual img {
width: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
}
.shape-stack {
min-height: 220px;
display: grid;
place-items: center;
position: relative;
}
.shape {
position: absolute;
display: block;
}
.shape.cube {
width: 8rem;
height: 8rem;
border-radius: 2rem;
background: linear-gradient(135deg, rgba(0, 194, 168, 0.95), rgba(5, 28, 50, 0.7));
transform: rotate(18deg);
}
.shape.sphere {
width: 5rem;
height: 5rem;
background: radial-gradient(circle at 30% 30%, #fff, rgba(255, 107, 74, 0.9));
border-radius: 50%;
top: 1rem;
right: 4rem;
}
.shape.ring {
width: 11rem;
height: 11rem;
border-radius: 50%;
border: 1.2rem solid rgba(125, 249, 215, 0.2);
left: 2rem;
bottom: 0;
}
.signal-search {
background: rgba(8, 17, 31, 0.66);
padding: 1rem;
border-radius: 1.5rem;
border: 1px solid rgba(125, 249, 215, 0.13);
}
.form-control,
.form-select {
background-color: rgba(255, 255, 255, 0.04);
border-color: rgba(169, 184, 202, 0.24);
color: var(--signal-text);
border-radius: 1rem;
min-height: 3.3rem;
}
.form-control:focus,
.form-select:focus,
.form-check-input:focus,
.btn:focus {
box-shadow: 0 0 0 0.22rem rgba(0, 194, 168, 0.22);
border-color: rgba(0, 194, 168, 0.72);
}
.form-control::placeholder {
color: rgba(169, 184, 202, 0.8);
}
.form-select option {
color: #08111f;
}
.hero-metrics .metric-card {
border: 1px solid rgba(125, 249, 215, 0.12);
border-radius: 1.2rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.03);
min-height: 100%;
}
.metric-card span {
display: block;
font-family: 'Space Grotesk', sans-serif;
font-size: 1.6rem;
margin-bottom: 0.2rem;
}
.metric-card small {
color: var(--signal-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.section-block {
padding-top: 1rem;
padding-bottom: 2rem;
}
.section-heading h2 {
font-size: clamp(1.7rem, 3vw, 2.8rem);
margin: 0;
}
.text-link {
color: var(--signal-accent);
text-decoration: none;
}
.topic-rail {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.topic-pill {
display: inline-flex;
align-items: center;
gap: 0.7rem;
padding: 0.95rem 1.25rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(169, 184, 202, 0.16);
text-decoration: none;
color: var(--signal-text);
transition: transform 0.2s ease, border-color 0.2s ease;
}
.topic-pill:hover,
.topic-pill:focus {
transform: translateY(-2px);
border-color: rgba(0, 194, 168, 0.45);
color: var(--signal-cream);
}
.topic-dot {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
box-shadow: 0 0 18px currentColor;
}
.story-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.2rem;
}
.story-grid-wide {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.story-card {
min-height: 100%;
transition: transform 0.22s ease, border-color 0.22s ease;
}
.story-card:hover,
.story-card:focus-within {
transform: translateY(-4px);
border-color: rgba(0, 194, 168, 0.4);
}
.story-card-inner,
.story-card,
.sidebar-card,
.article-body {
padding: 1.4rem;
}
.story-card h3,
.story-card h2,
.sidebar-card h3 {
font-size: 1.35rem;
margin-bottom: 0.8rem;
}
.story-card a,
.related-link,
.footer-links a {
color: var(--signal-text);
text-decoration: none;
}
.story-card p,
.sidebar-card p,
.source-list li span,
.related-link span {
color: var(--signal-muted);
line-height: 1.7;
}
.story-footer {
margin-top: auto;
}
.tall-card {
display: flex;
flex-direction: column;
}
.editorial-sidebar,
.article-form-card .row {
height: 100%;
}
.sidebar-card {
border-radius: 1.5rem;
}
.source-list,
.detail-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 1rem;
}
.source-list li,
.detail-list li {
display: grid;
gap: 0.35rem;
text-transform: none;
letter-spacing: normal;
}
.related-link {
display: grid;
gap: 0.35rem;
padding: 0.25rem 0;
}
.article-title {
font-size: clamp(2.2rem, 3vw, 3.4rem);
line-height: 1;
}
.article-body {
padding: 2rem;
}
.rich-copy p {
margin-bottom: 1.15rem;
}
.form-error ul {
list-style: none;
padding: 0;
margin: 0.5rem 0 0;
color: #ffac97;
}
.form-check-wrapper {
padding-top: 0.35rem;
}
.form-check-input {
background-color: rgba(255, 255, 255, 0.05);
border-color: rgba(169, 184, 202, 0.32);
}
.form-check-label {
margin-left: 0.4rem;
}
.success-card {
max-width: 50rem;
}
.signal-alert {
border: 1px solid rgba(0, 194, 168, 0.32);
background: rgba(0, 194, 168, 0.13);
color: var(--signal-text);
border-radius: 1rem;
}
.site-footer {
border-top: 1px solid rgba(169, 184, 202, 0.12);
margin-top: 2rem;
}
.footer-brand {
font-weight: 600;
}
.footer-links a {
color: rgba(245, 247, 251, 0.72);
}
.footer-links a:hover,
.footer-links a:focus {
color: var(--signal-accent);
}
@media (max-width: 991.98px) {
.display-title,
.small-title {
max-width: 100%;
}
.story-grid,
.story-grid-wide {
grid-template-columns: 1fr;
}
.hero-section {
padding-top: 2.5rem;
}
}
@media (max-width: 575.98px) {
.hero-panel,
.article-shell,
.article-form-card,
.success-card,
.story-card,
.sidebar-card,
.article-body {
padding: 1.2rem;
border-radius: 1.25rem;
}
.signal-search {
padding: 0.85rem;
}
}
.hero-bento-section {
padding-bottom: 1rem;
}
.signal-deck-panel {
min-height: 100%;
position: relative;
overflow: hidden;
}
.signal-deck-topline {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
}
.deck-timestamp,
.bento-label {
font-size: 0.76rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--signal-accent);
}
.deck-orbits {
position: relative;
min-height: 12rem;
margin-top: 1.5rem;
}
.deck-orbit {
position: absolute;
border-radius: 999px;
border: 1px solid rgba(125, 249, 215, 0.2);
background: radial-gradient(circle at 30% 30%, rgba(125, 249, 215, 0.18), rgba(8, 17, 31, 0.02));
animation: deckPulse 9s ease-in-out infinite;
}
.orbit-one {
width: 9rem;
height: 9rem;
top: 0;
left: 1rem;
}
.orbit-two {
width: 7rem;
height: 7rem;
right: 1.5rem;
top: 2rem;
animation-delay: -3s;
}
.orbit-three {
width: 4.5rem;
height: 4.5rem;
left: 45%;
bottom: 0.8rem;
animation-delay: -6s;
}
@keyframes deckPulse {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(0, -12px, 0) scale(1.04); }
}
.topic-rail-wide .topic-pill {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03));
}
.topic-rail-wide .topic-dot {
background: var(--topic-accent, var(--signal-primary));
color: var(--topic-accent, var(--signal-primary));
}
.bento-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 1.15rem;
align-items: stretch;
grid-auto-rows: minmax(88px, auto);
}
.bento-card {
position: relative;
overflow: hidden;
min-height: 100%;
display: flex;
flex-direction: column;
transition: transform 0.28s ease, border-color 0.28s ease, box-shadow 0.28s ease;
}
.bento-card::before {
content: '';
position: absolute;
inset: auto -10% -35% auto;
width: 11rem;
height: 11rem;
border-radius: 999px;
background: radial-gradient(circle, rgba(0, 194, 168, 0.18), transparent 70%);
pointer-events: none;
}
.bento-card.accent-secondary::before {
background: radial-gradient(circle, rgba(255, 107, 74, 0.18), transparent 70%);
}
.bento-card.accent-primary::before {
background: radial-gradient(circle, rgba(125, 249, 215, 0.22), transparent 70%);
}
.bento-card:hover,
.bento-card:focus-within {
transform: translateY(-6px);
border-color: rgba(125, 249, 215, 0.34);
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.42);
}
.bento-card-copy,
.bento-sidebar,
.bento-editor,
.bento-original {
padding: 1.45rem;
position: relative;
z-index: 1;
height: 100%;
}
.bento-feature {
grid-column: span 7;
grid-row: span 4;
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(220px, 0.8fr);
}
.bento-feature .bento-card-copy {
display: flex;
flex-direction: column;
}
.bento-feature .bento-visual {
min-height: 100%;
position: relative;
padding: 1.45rem 1.45rem 1.45rem 0;
display: flex;
align-items: stretch;
}
.bento-feature .bento-visual img,
.bento-feature .shape-stack {
width: 100%;
border-radius: 1.35rem;
overflow: hidden;
min-height: 100%;
}
.bento-feature .bento-visual img {
aspect-ratio: 4 / 5;
object-fit: cover;
}
.bento-wide {
grid-column: span 5;
grid-row: span 2;
}
.bento-tall {
grid-column: span 4;
grid-row: span 3;
}
.bento-compact {
grid-column: span 3;
grid-row: span 2;
}
.bento-sidebar {
grid-column: span 4;
grid-row: span 3;
}
.bento-editor {
grid-column: span 4;
grid-row: span 3;
}
.bento-original {
grid-column: span 4;
grid-row: span 2;
}
.bento-grid h3 {
font-size: clamp(1.2rem, 2vw, 2rem);
line-height: 1.05;
letter-spacing: -0.04em;
margin-bottom: 0.8rem;
}
.bento-grid p {
color: var(--signal-muted);
line-height: 1.7;
}
.drift-card {
will-change: transform;
}
@media (max-width: 1199.98px) {
.bento-feature {
grid-column: span 12;
}
.bento-wide,
.bento-sidebar,
.bento-editor,
.bento-original {
grid-column: span 6;
}
.bento-tall,
.bento-compact {
grid-column: span 6;
grid-row: span 2;
}
}
@media (max-width: 991.98px) {
.bento-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.bento-feature,
.bento-wide,
.bento-tall,
.bento-compact,
.bento-sidebar,
.bento-editor,
.bento-original {
grid-column: span 2;
grid-row: auto;
}
.bento-feature {
grid-template-columns: 1fr;
}
.bento-feature .bento-visual {
padding: 0 1.45rem 1.45rem;
}
}
@media (max-width: 575.98px) {
.signal-deck-topline {
flex-direction: column;
align-items: flex-start;
}
.bento-grid {
grid-template-columns: 1fr;
}
.bento-feature,
.bento-wide,
.bento-tall,
.bento-compact,
.bento-sidebar,
.bento-editor,
.bento-original {
grid-column: span 1;
}
}

View File

@ -0,0 +1,33 @@
document.addEventListener('DOMContentLoaded', () => {
const cards = Array.from(document.querySelectorAll('[data-drift]'));
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!cards.length || reduceMotion) {
return;
}
const updateCard = (card, xRatio, yRatio) => {
const depth = Number(card.dataset.drift || 10);
const offsetX = xRatio * depth;
const offsetY = yRatio * depth;
card.style.transform = `translate3d(${offsetX.toFixed(2)}px, ${offsetY.toFixed(2)}px, 0)`;
};
const resetCard = (card) => {
card.style.transform = '';
};
cards.forEach((card, index) => {
card.style.animation = `floaty ${9 + (index % 4)}s ease-in-out ${index * -0.8}s infinite`;
});
window.addEventListener('mousemove', (event) => {
const xRatio = (event.clientX / window.innerWidth - 0.5) * 1.2;
const yRatio = (event.clientY / window.innerHeight - 0.5) * 1.2;
cards.forEach((card) => updateCard(card, xRatio, yRatio));
}, { passive: true });
window.addEventListener('mouseleave', () => {
cards.forEach(resetCard);
});
});