Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c559e6fffc |
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/rss.cpython-311.pyc
Normal file
BIN
core/__pycache__/rss.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
62
core/forms.py
Normal 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
|
||||
0
core/management/__init__.py
Normal file
0
core/management/__init__.py
Normal file
BIN
core/management/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
core/management/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
0
core/management/commands/__init__.py
Normal file
0
core/management/commands/__init__.py
Normal file
BIN
core/management/commands/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
core/management/commands/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/management/commands/__pycache__/import_rss.cpython-311.pyc
Normal file
BIN
core/management/commands/__pycache__/import_rss.cpython-311.pyc
Normal file
Binary file not shown.
15
core/management/commands/import_rss.py
Normal file
15
core/management/commands/import_rss.py
Normal 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).'))
|
||||
71
core/migrations/0001_initial.py
Normal file
71
core/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
88
core/migrations/0002_seed_newsroom_data.py
Normal file
88
core/migrations/0002_seed_newsroom_data.py
Normal 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),
|
||||
]
|
||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
@ -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
161
core/rss.py
Normal 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
|
||||
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
78
core/templates/core/article_form.html
Normal file
78
core/templates/core/article_form.html
Normal 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 %}
|
||||
50
core/templates/core/article_list.html
Normal file
50
core/templates/core/article_list.html
Normal 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 %}
|
||||
19
core/templates/core/article_submit_success.html
Normal file
19
core/templates/core/article_submit_success.html
Normal 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 %}
|
||||
@ -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 %}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
|
||||
139
core/views.py
139
core/views.py
@ -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)
|
||||
|
||||
@ -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
33
static/js/newsroom.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
33
staticfiles/js/newsroom.js
Normal file
33
staticfiles/js/newsroom.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user