diff --git a/.assets-revision b/.assets-revision index 77578c0..8dbb2a9 100644 --- a/.assets-revision +++ b/.assets-revision @@ -6,4 +6,5 @@ # sha). Override at runtime with the ASSETS_REVISION env var. repo: ChilleD/WebHarbor -revision: main +# Pinned to upstream dataset main at resolve time (re-bump after HF PR merges, e.g. discussions/10). +revision: 110675d643b00b5197227bd92ad225274142930f diff --git a/.gitignore b/.gitignore index c2efc04..94463f4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,10 @@ sites/*/static/external_cache/ # ============================================================= # Intermediate / volatile — never committed anywhere. # ============================================================= -sites/*/scraped_data/ # scrape pipeline intermediate; runtime data lives in instance_seed/*.db -sites/*/instance/ # rebuilt at every container boot from instance_seed/ +# scrape pipeline intermediate; runtime data lives in instance_seed/*.db +sites/*/scraped_data/ +# rebuilt at every container boot from instance_seed/ +sites/*/instance/ sites/*/venv/ # HF download metadata produced by `hf download`. @@ -92,4 +94,13 @@ secrets.json # ============================================================ # Agent demo results # ============================================================= -agent_demo/runs/ \ No newline at end of file +agent_demo/runs/ +agent_demo/.playwright/ + +# ============================================================ +# Local-only scratch/test artifacts +# ============================================================= +.local_ignore/ + +# Editor / local history (never commit) +.history/ diff --git a/Dockerfile b/Dockerfile index 991e5ab..43088ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 15 Flask mirror sites + control plane on :8101. +# 17 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -33,6 +33,6 @@ COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py RUN chmod +x /opt/websyn_start.sh -EXPOSE 8101 40000-40014 +EXPOSE 8101 40000-40016 CMD ["/opt/websyn_start.sh"] diff --git a/control_server.py b/control_server.py index c255253..368826d 100644 --- a/control_server.py +++ b/control_server.py @@ -26,7 +26,7 @@ 'allrecipes', 'amazon', 'apple', 'arxiv', 'bbc_news', 'booking', 'github', 'google_flights', 'google_map', 'google_search', 'huggingface', 'wolfram_alpha', 'cambridge_dictionary', - 'coursera', 'espn', + 'coursera', 'espn', 'youtube', 'weather', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/weather/_health.py b/sites/weather/_health.py new file mode 100644 index 0000000..801fd1c --- /dev/null +++ b/sites/weather/_health.py @@ -0,0 +1,4 @@ +"""Per-site health probe.""" + +def health(): + return {"ok": True, "site": "weather"} diff --git a/sites/weather/app.py b/sites/weather/app.py new file mode 100644 index 0000000..7a39f2a --- /dev/null +++ b/sites/weather/app.py @@ -0,0 +1,878 @@ +import importlib.util +import os +from datetime import datetime + +from flask import Flask, flash, redirect, render_template, request, url_for +from flask_bcrypt import Bcrypt +from flask_login import (LoginManager, UserMixin, current_user, login_required, + login_user, logout_user) +from flask_sqlalchemy import SQLAlchemy +from flask_wtf import FlaskForm +from flask_wtf.csrf import CSRFProtect, generate_csrf +from wtforms import PasswordField, StringField +from wtforms.validators import DataRequired, Email, EqualTo, Length, Optional + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +MIRROR_REFERENCE_DATE = datetime(2024, 2, 15, 8, 0, 0) +MIRROR_REFERENCE_DATE_LABEL = MIRROR_REFERENCE_DATE.strftime('%B %-d, %Y') +EXPLORE_TOPICS = [ + {'key': 'top-stories', 'slug': 'top-stories', 'label': 'Top Stories'}, + {'key': 'el-nino', 'slug': 'el-nino', 'label': 'El Nino'}, + {'key': 'home-garden', 'slug': 'home-and-garden', 'label': 'Home & Garden'}, + {'key': 'allergies', 'slug': 'allergies', 'label': 'Allergies'}, + {'key': 'space', 'slug': 'space', 'label': 'Space'}, + {'key': 'product-guides', 'slug': 'product-guides', 'label': 'Product Guides'}, + {'key': 'animals', 'slug': 'animals', 'label': 'Animals'}, + {'key': 'travel', 'slug': 'travel', 'label': 'Travel'}, + {'key': 'bike', 'slug': 'bike', 'label': 'Bike'}, + {'key': 'seasonal', 'slug': 'seasonal', 'label': 'Seasonal'}, +] +EXPLORE_TOPIC_CONTENT = { + 'top-stories': { + 'hero_title': 'A Super El Niño Is Increasingly Likely, And It Could Be Record Strong', + 'hero_description': "We’re trending toward El Niño, and by later this year, it could become one of the strongest on record.", + 'hero_image': '/static/images/weather/upstream/explore/el-nino__00.jpg', + 'items': [ + {'title': 'El Niño: What It Is And How It Can Affect Your Weather', 'description': 'You may have heard the term El Niño thrown around quite a bit, but what exactly does it mean?', 'duration': '0:50', 'image': '/static/images/weather/upstream/explore/el-nino__01.jpg'}, + {'title': 'Strong El Niño Years Can Bring May Hurricanes', 'description': 'In recent El Niño years, there has often been a named storm in May.', 'duration': '1:25', 'image': '/static/images/weather/upstream/explore/el-nino__02.png'}, + {'title': 'Why Does El Niño Affect Summer Weather Significantly Less Than Winter?', 'description': 'The summer before El Niño can feel like a regular summer.', 'duration': '', 'image': '/static/images/weather/upstream/explore/el-nino__03.jpg'}, + ], + }, + 'el-nino': { + 'hero_title': 'A Super El Niño Is Increasingly Likely, And It Could Be Record Strong', + 'hero_description': "We’re trending toward El Niño, and by later this year, it could become one of the strongest on record.", + 'hero_image': '/static/images/weather/upstream/explore/el-nino__00.jpg', + 'items': [ + {'title': 'El Niño: What It Is And How It Can Affect Your Weather', 'description': 'You may have heard the term El Niño thrown around quite a bit, but what exactly does it mean?', 'duration': '0:50', 'image': '/static/images/weather/upstream/explore/el-nino__01.jpg'}, + {'title': 'Strong El Niño Years Can Bring May Hurricanes', 'description': 'In recent El Niño years, there has often been a named storm in May.', 'duration': '1:25', 'image': '/static/images/weather/upstream/explore/el-nino__02.png'}, + {'title': 'Why Does El Niño Affect Summer Weather Significantly Less Than Winter?', 'description': 'The summer before El Niño can feel like a regular summer.', 'duration': '', 'image': '/static/images/weather/upstream/explore/el-nino__03.jpg'}, + {'title': "Eastern Pacific Hurricane Season Starts Friday. A Potential Super El Niño And A 'Wild Card' Could Boost It.", 'description': 'A strong El Niño could turbocharge the Eastern Pacific hurricane season.', 'duration': '', 'image': '/static/images/weather/upstream/explore/el-nino__04.png'}, + ], + }, + 'home-garden': { + 'hero_title': 'Farmer Shares the 5 Vegetables You Should Never Plant Together', + 'hero_description': "It's not about what you plant, but where you plant it.", + 'hero_image': '/static/images/weather/upstream/explore/home-and-garden__00.jpg', + 'items': [ + {'title': "Yelp's 2026 Home Trends: Smarter, stylish spaces for summer", 'description': 'Expect to see personality-driven homes with purpose.', 'duration': '', 'image': '/static/images/weather/upstream/explore/home-and-garden__01.jpg'}, + {'title': '10 Vegetables You Can Grow in Part Shade', 'description': "Here's how to maximize your harvest with limited sunlight.", 'duration': '', 'image': '/static/images/weather/upstream/explore/home-and-garden__02.jpg'}, + {'title': 'Aromatic Plants for a Home Garden Design', 'description': 'Easy-to-find scented plants for a romantic garden.', 'duration': '', 'image': '/static/images/weather/upstream/explore/home-and-garden__03.jpg'}, + ], + }, + 'allergies': { + 'hero_title': '5 Best Cities For Allergy Sufferers That Travelers Love', + 'hero_description': 'Top destination picks for travelers who want fewer allergy symptoms.', + 'hero_image': '/static/images/weather/upstream/explore/allergies__00.jpg', + 'items': [ + {'title': 'Allergy Season Grows Longer – Especially In These Places', 'description': 'Rising temperatures and shifting pollen windows are changing seasonal patterns.', 'duration': '', 'image': '/static/images/weather/upstream/explore/allergies__01.jpg'}, + {'title': 'Here’s How To Really Get Allergy Relief From Nasal Spray', 'description': 'Simple technique changes can make treatment far more effective.', 'duration': '', 'image': '/static/images/weather/upstream/explore/allergies__02.jpg'}, + {'title': 'The Worst European Summer Destinations For Allergy Sufferers', 'description': 'Some iconic summer routes can spike pollen and mold exposure.', 'duration': '', 'image': '/static/images/weather/upstream/explore/allergies__03.jpg'}, + ], + }, + 'space': { + 'hero_title': 'Lightning From Space? Storm Over Kansas Caught in Orbit', + 'hero_description': 'Orbital imagery captured dramatic lightning activity over the Plains.', + 'hero_image': '/static/images/weather/upstream/explore/space__00.jpg', + 'items': [ + {'title': "Here's The Best Way To Watch A Meteor Shower", 'description': 'Tips for timing, darkness, and viewing angles for meteor showers.', 'duration': '', 'image': '/static/images/weather/upstream/explore/space__01.jpg'}, + {'title': 'Full Flower Moon Welcomes May: Best Pics From Around The Globe', 'description': 'A visual roundup of moonrise photos from around the world.', 'duration': '', 'image': '/static/images/weather/upstream/explore/space__02.jpg'}, + {'title': 'Bright Fireball Streaks Over Washington, Oregon', 'description': 'Witness accounts describe a vivid streaking object lighting up the sky.', 'duration': '', 'image': '/static/images/weather/upstream/explore/space__03.jpg'}, + ], + }, + 'product-guides': { + 'hero_title': 'Product Reviews & Deals', + 'hero_description': 'Practical weather-focused buying guides and product picks.', + 'hero_image': '/static/images/weather/upstream/explore/seasonal__00.jpg', + 'items': [ + {'title': 'What You Need To Know To Be Prepared For Hurricane Season', 'description': 'Core preparation checklist before peak storm months.', 'duration': '0:48', 'image': '/static/images/weather/upstream/explore/seasonal__01.jpg'}, + {'title': 'Could You Travel To The Caribbean This Hurricane Season?', 'description': 'Travel planning tips and risk windows for tropical trips.', 'duration': '1:33', 'image': '/static/images/weather/upstream/explore/seasonal__04.png'}, + {'title': 'One Month Until Hurricane Season: What We Know', 'description': 'Latest outlook and key signals heading into the season.', 'duration': '1:22', 'image': '/static/images/weather/upstream/explore/seasonal__02.png'}, + ], + }, + 'animals': { + 'hero_title': '7 Rattlesnake Hotspots in Nevada Most People Don’t Know About', + 'hero_description': 'From Lake Tahoe to Great Basin National Park, see where these snakes are most common.', + 'hero_image': '/static/images/weather/upstream/explore/animals__00.jpg', + 'items': [ + {'title': 'The Best Cat Breeds for Young Kids', 'description': 'Find family-friendly cat breeds for households with children.', 'duration': '', 'image': '/static/images/weather/upstream/explore/animals__01.jpg'}, + {'title': 'Tiny Baby Geese Get Blown Over by the Wind in Adorable Video', 'description': 'Windy conditions turn into a surprisingly cute moment.', 'duration': '', 'image': '/static/images/weather/upstream/explore/animals__02.jpg'}, + {'title': 'What It Means When Crows Call Around Your Home', 'description': 'Why this behavior happens and what it may signal.', 'duration': '', 'image': '/static/images/weather/upstream/explore/animals__03.jpg'}, + ], + }, + 'travel': { + 'hero_title': 'Ask A Met: Why Is It Unsafe To Fly In A Winter Storm?', + 'hero_description': 'Each week, meteorologists answer weather questions from readers.', + 'hero_image': '/static/images/weather/upstream/explore/travel__00.jpg', + 'items': [ + {'title': 'Where In The World Is ... Fly Geyser?', 'description': 'Can you guess where in the world Fly Geyser is?', 'duration': '', 'image': '/static/images/weather/upstream/explore/travel__01.jpg'}, + {'title': 'Forget Summer, These National Parks Are Even Better In The Snow', 'description': 'These parks transform into snow-dusted destinations in winter.', 'duration': '', 'image': '/static/images/weather/upstream/explore/travel__02.jpg'}, + {'title': 'Where In The World Is ... Goblin Valley?', 'description': 'A landscape of unusual stone formations and surreal terrain.', 'duration': '', 'image': '/static/images/weather/upstream/explore/travel__03.jpg'}, + ], + }, + 'bike': { + 'hero_title': '5 Countries, 3 Continents, and One Tiny Bike: Our Long-Term Spawn Yoji Review', + 'hero_description': 'What really makes a good first pedal bike for kids?', + 'hero_image': '/static/images/weather/upstream/explore/bike__00.jpg', + 'items': [ + {'title': 'California Draws a Long-Overdue Line Between E-Bikes and E-Motos', 'description': 'A new bill aims to clearly separate classes of electric two-wheelers.', 'duration': '', 'image': '/static/images/weather/upstream/explore/bike__01.jpg'}, + {'title': "Utah’s 3,100-Mile Bike 'Interstate' Could Revolutionize Riding Across the State", 'description': 'A statewide paved network promises safer access and broader connectivity.', 'duration': '', 'image': '/static/images/weather/upstream/explore/bike__02.jpg'}, + {'title': 'Faster than Death – First Aid Meets Mountain Bikes', 'description': 'Why emergency readiness matters when riding remote trails.', 'duration': '', 'image': '/static/images/weather/upstream/explore/bike__03.jpg'}, + ], + }, + 'seasonal': { + 'hero_title': 'What You Need To Know To Be Prepared For Hurricane Season', + 'hero_description': 'Actionable preparation guidance before storms ramp up.', + 'hero_image': '/static/images/weather/upstream/explore/seasonal__00.jpg', + 'items': [ + {'title': 'The Anatomy of a Hurricane', 'description': 'A breakdown of storm structure and key parts of a hurricane.', 'duration': '2:35', 'image': '/static/images/weather/upstream/explore/seasonal__01.jpg'}, + {'title': 'One Month Until Hurricane Season: What We Know', 'description': 'Current outlook details one month out from the season start.', 'duration': '1:22', 'image': '/static/images/weather/upstream/explore/seasonal__02.png'}, + {'title': 'Why The Offseason Is Surprisingly Busy For Hurricane Experts', 'description': 'Forecast teams continue active preparation during the quieter months.', 'duration': '1:23', 'image': '/static/images/weather/upstream/explore/seasonal__03.jpg'}, + ], + }, +} +VIDEO_FEATURED = { + 'title': 'Storm-Weary Plains, Midwest To See More Severe Weather', + 'time_label': '1 day ago', + 'updated': 'Updated: May 13, 2026, 9:23 am EDT', + 'published': 'Published: May 13, 2026, 9:23 am EDT', + 'description': 'The storm-weary Plains and Midwest are looking at the potential for more severe weather this weekend into early next week. Damaging wind gusts, large hail, tornadoes and heavy rain will all be possible each day.', + 'duration': '0:37', + 'image': '/static/images/weather/upstream/video/severe__00.jpg', + 'upstream_url': 'https://weather.com/storms/severe/video/storm-weary-plains-midwest-more-severe-weather-weekend', +} +VIDEO_NEXT_UP = [ + {'title': 'PGA Championship Weather Forecast: Cool Start, Then A Warmup', 'duration': '0:48', 'image': '/static/images/weather/upstream/video/severe__01.jpg'}, + {'title': 'West Sizzles, But Heat Shifts East This Week-Weekend', 'duration': '0:50', 'image': '/static/images/weather/upstream/video/severe__02.jpg'}, + {'title': 'What You Need To Know To Be Prepared For Hurricane Season', 'duration': '0:48', 'image': '/static/images/weather/upstream/video/severe__03.jpg'}, + {'title': 'Could You Travel To The Caribbean This Hurricane Season?', 'duration': '1:33', 'image': '/static/images/weather/upstream/video/severe__04.jpg'}, + {'title': 'One Month Until Hurricane Season: What We Know', 'duration': '1:22', 'image': '/static/images/weather/upstream/video/severe__05.png'}, + {'title': 'Why The Major Shift In The May Temperature Outlook?', 'duration': '1:27', 'image': '/static/images/weather/upstream/video/severe__06.jpg'}, + {'title': 'Tornado Wrecks Homes In Germantown, Illinois', 'duration': '0:31', 'image': '/static/images/weather/upstream/video/severe__07.jpg'}, + {'title': 'Tornado Tosses Over Train Cars In Oswego, Kansas', 'duration': '0:27', 'image': '/static/images/weather/upstream/video/severe__08.jpg'}, + {'title': 'Inside The Aftermath Of Deadly Tornado In Runaway Bay, Texas', 'duration': '0:34', 'image': '/static/images/weather/upstream/video/severe__09.jpg'}, +] + +MEDIA_IMAGE_PATH_ALIASES = { + '/static/images/weather/upstream/media/06-where-has-it-spread-dozens-of-passengers-left-hantavirus-cruise-ship-after-first.jpg': + '/static/images/weather/upstream/media/06-where-has-it-spread-dozens-of-passengers-left-hantavirus-cruise-ship-after-first-death.jpg', +} + + +def normalize_media_image_path(path: str) -> str: + return MEDIA_IMAGE_PATH_ALIASES.get(path, path) + + +def mirror_now() -> datetime: + return MIRROR_REFERENCE_DATE + + +def prefers_metric_units() -> bool: + return bool(current_user.is_authenticated and current_user.preferred_units == 'metric') + + +def format_temp(temp_f: int | float) -> str: + if prefers_metric_units(): + return f"{round((temp_f - 32) * 5 / 9)}°C" + return f"{round(temp_f)}°F" + + +def format_wind(speed_mph: int | float, direction: str | None = None) -> str: + if prefers_metric_units(): + speed = f"{round(speed_mph * 1.60934)} km/h" + else: + speed = f"{round(speed_mph)} mph" + return f"{speed} {direction}".strip() if direction else speed + + +def format_distance(distance_mi: int | float) -> str: + if prefers_metric_units(): + return f"{round(distance_mi * 1.60934)} km" + return f"{round(distance_mi)} mi" + + +def build_media_sections(cards: list[dict], offset: int = 0) -> dict: + if not cards: + return { + 'hero_card': None, + 'editor_card': None, + 'seasonal_card': None, + 'tile_cards': [], + 'headline_cards': [], + 'rail_cards': [], + 'video_cards': [], + } + count = len(cards) + + def at(idx: int): + return cards[(offset + idx) % count] + + return { + 'hero_card': at(0), + 'editor_card': at(2), + 'seasonal_card': at(3), + 'tile_cards': [at(i) for i in range(1, min(5, count))], + 'headline_cards': [at(i) for i in range(5, min(10, count))], + 'rail_cards': [at(i) for i in range(6, min(10, count))], + 'video_cards': [at(i) for i in range(0, min(4, count))], + } + + +def get_media_sections(offset: int = 0) -> dict: + cards = [ + {'title': row.title, 'image': normalize_media_image_path(row.image_path), 'watch_url': row.watch_url} + for row in WeatherMediaCard.query.order_by(WeatherMediaCard.position.asc()).limit(16).all() + ] + return build_media_sections(cards, offset=offset) + + +def get_topic_media_sections(topic_key: str, topic_map: dict[str, list[int]]) -> dict: + rows = WeatherMediaCard.query.order_by(WeatherMediaCard.position.asc()).all() + by_position = {row.position: row for row in rows} + selected_positions = topic_map.get(topic_key, []) + + selected_cards = [] + for position in selected_positions: + row = by_position.get(position) + if row is None: + continue + selected_cards.append({'title': row.title, 'image': normalize_media_image_path(row.image_path), 'watch_url': row.watch_url}) + + if len(selected_cards) < 6: + existing_titles = {card['title'] for card in selected_cards} + for row in rows: + if row.title in existing_titles: + continue + selected_cards.append({'title': row.title, 'image': normalize_media_image_path(row.image_path), 'watch_url': row.watch_url}) + if len(selected_cards) >= 8: + break + + return build_media_sections(selected_cards, offset=0) + + +def resolve_topic(topics: list[dict], selected_key: str | None): + topic_map = {topic['key']: topic for topic in topics} + fallback = topics[0] + selected = topic_map.get((selected_key or '').strip(), fallback) + return selected + + +def resolve_topic_by_slug(topics: list[dict], topic_slug: str | None): + topic_map = {topic['slug']: topic for topic in topics} + fallback = topics[0] + selected = topic_map.get((topic_slug or '').strip(), fallback) + return selected + + +app = Flask(__name__, template_folder=os.path.join(BASE_DIR, 'templates'), static_folder=os.path.join(BASE_DIR, 'static')) +app.config['SECRET_KEY'] = 'webharbor-weather-dev-key' +app.config['SQLALCHEMY_DATABASE_URI'] = f"sqlite:///{os.path.join(BASE_DIR, 'instance', 'weather.db')}" +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['WTF_CSRF_TIME_LIMIT'] = None +os.makedirs(os.path.join(BASE_DIR, 'instance'), exist_ok=True) + +db = SQLAlchemy(app) +bcrypt = Bcrypt(app) +login_manager = LoginManager(app) +login_manager.login_view = 'login' +login_manager.login_message = 'Please sign in to continue.' +csrf = CSRFProtect(app) + + +class User(db.Model, UserMixin): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(120), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + full_name = db.Column(db.String(120), nullable=False) + preferred_units = db.Column(db.String(20), default='imperial') + home_location_id = db.Column(db.Integer, db.ForeignKey('locations.id'), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + saved_locations = db.relationship('SavedLocation', backref='user', lazy=True, cascade='all, delete-orphan') + + def set_password(self, raw: str): + self.password_hash = bcrypt.generate_password_hash(raw).decode('utf-8') + + def check_password(self, raw: str) -> bool: + return bcrypt.check_password_hash(self.password_hash, raw) + + +class Location(db.Model): + __tablename__ = 'locations' + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(80), unique=True, nullable=False, index=True) + city = db.Column(db.String(120), nullable=False) + region = db.Column(db.String(120), default='') + country = db.Column(db.String(120), default='United States') + search_label = db.Column(db.String(180), default='') + hero_image = db.Column(db.String(300), default='') + radar_image = db.Column(db.String(300), default='') + summary = db.Column(db.Text, default='') + + +class CurrentConditions(db.Model): + __tablename__ = 'current_conditions' + id = db.Column(db.Integer, primary_key=True) + location_id = db.Column(db.Integer, db.ForeignKey('locations.id'), nullable=False) + temperature_f = db.Column(db.Integer, default=70) + feels_like_f = db.Column(db.Integer, default=70) + humidity = db.Column(db.Integer, default=50) + wind_mph = db.Column(db.Integer, default=5) + wind_direction = db.Column(db.String(10), default='NW') + uv_index = db.Column(db.Integer, default=3) + visibility_mi = db.Column(db.Integer, default=10) + air_quality = db.Column(db.String(40), default='Good') + condition_label = db.Column(db.String(80), default='Clear') + updated_at = db.Column(db.DateTime, default=mirror_now) + location = db.relationship('Location') + + @property + def temperature_c(self): + return round((self.temperature_f - 32) * 5 / 9) + + @property + def feels_like_c(self): + return round((self.feels_like_f - 32) * 5 / 9) + + +class DailyForecast(db.Model): + __tablename__ = 'daily_forecasts' + id = db.Column(db.Integer, primary_key=True) + location_id = db.Column(db.Integer, db.ForeignKey('locations.id'), nullable=False) + forecast_date = db.Column(db.Date, nullable=False) + high_f = db.Column(db.Integer, default=70) + low_f = db.Column(db.Integer, default=55) + precip_pct = db.Column(db.Integer, default=10) + humidity = db.Column(db.Integer, default=50) + wind_mph = db.Column(db.Integer, default=8) + uv_index = db.Column(db.Integer, default=4) + sunrise = db.Column(db.String(20), default='6:48 AM') + sunset = db.Column(db.String(20), default='5:39 PM') + condition_label = db.Column(db.String(80), default='Partly Cloudy') + location = db.relationship('Location') + + @property + def label(self): + delta = (self.forecast_date - MIRROR_REFERENCE_DATE.date()).days + if delta == 0: + return 'Today' + if delta == 1: + return 'Tomorrow' + return self.forecast_date.strftime('%a') + + +class HourlyForecast(db.Model): + __tablename__ = 'hourly_forecasts' + id = db.Column(db.Integer, primary_key=True) + location_id = db.Column(db.Integer, db.ForeignKey('locations.id'), nullable=False) + forecast_time = db.Column(db.DateTime, nullable=False) + temperature_f = db.Column(db.Integer, default=70) + precip_pct = db.Column(db.Integer, default=10) + wind_mph = db.Column(db.Integer, default=5) + condition_label = db.Column(db.String(80), default='Clear') + location = db.relationship('Location') + + +class SevereAlert(db.Model): + __tablename__ = 'severe_alerts' + id = db.Column(db.Integer, primary_key=True) + location_id = db.Column(db.Integer, db.ForeignKey('locations.id'), nullable=False) + alert_type = db.Column(db.String(120), nullable=False) + severity = db.Column(db.String(20), default='Moderate') + headline = db.Column(db.String(255), default='') + details = db.Column(db.Text, default='') + expires_at = db.Column(db.DateTime, nullable=False) + location = db.relationship('Location') + + +class SavedLocation(db.Model): + __tablename__ = 'saved_locations' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + location_id = db.Column(db.Integer, db.ForeignKey('locations.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + location = db.relationship('Location') + + +class SiteAsset(db.Model): + __tablename__ = 'site_assets' + id = db.Column(db.Integer, primary_key=True) + asset_key = db.Column(db.String(80), unique=True, nullable=False, index=True) + asset_path = db.Column(db.String(300), nullable=False) + + +class WeatherMediaCard(db.Model): + __tablename__ = 'weather_media_cards' + id = db.Column(db.Integer, primary_key=True) + position = db.Column(db.Integer, nullable=False, default=0) + title = db.Column(db.String(255), nullable=False) + image_path = db.Column(db.String(300), nullable=False) + watch_url = db.Column(db.String(500), default='') + + +class LoginForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired()]) + + +class RegisterForm(FlaskForm): + full_name = StringField('Full name', validators=[DataRequired(), Length(min=2, max=120)]) + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired(), Length(min=8)]) + confirm_password = PasswordField('Confirm password', validators=[DataRequired(), EqualTo('password')]) + + +class SearchForm(FlaskForm): + q = StringField('Search', validators=[Optional()]) + + +@login_manager.user_loader +def load_user(user_id: str): + return db.session.get(User, int(user_id)) + + +@app.context_processor +def inject_globals(): + primary_location = None + slug = (request.view_args or {}).get('slug') + if slug: + primary_location = Location.query.filter_by(slug=slug).first() + elif request.endpoint == 'search': + query = (request.args.get('q') or '').strip() + if query: + matches = search_locations(query) + if matches: + primary_location = matches[0] + if primary_location is None and current_user.is_authenticated and current_user.home_location_id: + primary_location = db.session.get(Location, current_user.home_location_id) + if primary_location is None: + primary_location = Location.query.filter_by(slug='new-york-ny').first() + primary_conditions = CurrentConditions.query.filter_by(location_id=primary_location.id).first() if primary_location else None + logo_asset = SiteAsset.query.filter_by(asset_key='brand_logo').first() + brand_logo_path = logo_asset.asset_path if logo_asset and logo_asset.asset_path else url_for('static', filename='icons/weather-logo-og.png') + return { + 'mirror_reference_date_label': MIRROR_REFERENCE_DATE_LABEL, + 'generate_csrf': generate_csrf, + 'primary_location': primary_location, + 'primary_conditions': primary_conditions, + 'brand_logo_path': brand_logo_path, + 'unit_label': '°C' if prefers_metric_units() else '°F', + 'fmt_temp': format_temp, + 'fmt_wind': format_wind, + 'fmt_distance': format_distance, + } + + +def homepage_story_data(): + scraped_cards = [ + {'title': row.title, 'image': normalize_media_image_path(row.image_path), 'watch_url': row.watch_url} + for row in WeatherMediaCard.query.order_by(WeatherMediaCard.position.asc()).limit(16).all() + ] + media_sections = build_media_sections(scraped_cards, offset=0) + hero_image = '/static/images/weather/hero/feature-story.jpg' + editor_image = '/static/images/weather/hero/editor-pick.jpg' + seasonal_image = '/static/images/weather/hero/seasonal-tip.png' + top_story_tiles = [] + top_story_headlines = [] + right_rail_cards = [] + if scraped_cards: + top_story_tiles = [ + { + 'title': card['title'], + 'image': card['image'], + } + for card in scraped_cards[1:5] + ] + top_story_headlines = [card['title'] for card in scraped_cards[5:10]] + right_rail_cards = [ + { + 'title': card['title'], + 'image': card['image'], + } + for card in scraped_cards[6:10] + ] + if not top_story_tiles: + top_story_tiles = [ + { + 'title': 'Traveling Soon? Here Are The Maps You Need', + 'image': hero_image, + }, + { + 'title': "The Arctic Is On Fire - And No, It's Not Normal", + 'image': hero_image, + }, + { + 'title': "Home Insurance Disasters You're Not Prepared For", + 'image': seasonal_image, + }, + { + 'title': 'See Tiny Kittens Rescued From Mississippi Tornado Wreckage', + 'image': editor_image, + }, + ] + if not top_story_headlines: + top_story_headlines = [ + 'How To Stay Bear-Aware While Hiking National Parks', + 'Scientists Warn New Orleans Residents May Need To Begin Relocation Planning Now, Study Finds', + 'This Major World Capital Is Sinking 10 Inches A Year', + 'Lightning From Space? Storm Over Kansas Caught in Orbit', + 'New Tick-Borne Illness Concern Is Growing', + ] + if not right_rail_cards: + right_rail_cards = [ + { + 'title': '17 Popular Sun Shirts For Men And Women', + 'image': seasonal_image, + }, + { + 'title': 'Why You Need A Silk Pillowcase (And Our Top Picks)', + 'image': hero_image, + }, + { + 'title': '12 Travel Essentials To Stash In Your Carry-On', + 'image': editor_image, + }, + { + 'title': "Last Minute Mother's Day Gifts That Can Still Arrive On Time", + 'image': hero_image, + }, + ] + hero_story_card = media_sections['hero_card'] + editor_card = media_sections['editor_card'] + seasonal_card = media_sections['seasonal_card'] + video_cards = media_sections['video_cards'] + return { + 'hero_story': { + 'title': hero_story_card['title'] if hero_story_card else 'Deadly Fungal Storms Sweeping The US', + 'summary': '', + 'image': hero_story_card['image'] if hero_story_card else hero_image, + }, + 'editor_pick': { + 'title': editor_card['title'] if editor_card else 'See Tiny Kittens Rescued From Mississippi Tornado Wreckage', + 'image': editor_card['image'] if editor_card else editor_image, + }, + 'seasonal_tip': { + 'title': seasonal_card['title'] if seasonal_card else 'Seasonal Tips for Late-Winter Temperature Swings', + 'image': seasonal_card['image'] if seasonal_card else seasonal_image, + }, + 'top_story_tiles': top_story_tiles, + 'top_story_headlines': top_story_headlines, + 'right_rail_cards': right_rail_cards, + 'video_cards': video_cards, + } + + +def build_homepage_view_model(featured_locations: list[Location], conditions_by_slug: dict, forecast_hint): + lead_location = next((location for location in featured_locations if location.slug == 'new-york-ny'), featured_locations[0] if featured_locations else None) + lead_condition = conditions_by_slug.get(lead_location.slug) if lead_location else None + stories = homepage_story_data() + spotlight_modules = None + if lead_location and lead_condition: + lead_forecast = DailyForecast.query.filter_by(location_id=lead_location.id).order_by(DailyForecast.forecast_date.asc()).limit(2).all() + spotlight_modules = health_activity_modules(lead_location, lead_condition, lead_forecast) + return { + 'lead_location': lead_location, + 'lead_condition': lead_condition, + 'featured_locations': featured_locations, + 'conditions_by_slug': conditions_by_slug, + 'forecast_hint': forecast_hint, + 'spotlight_modules': spotlight_modules, + **stories, + } + + +def health_activity_modules(location, conditions, forecast): + today = forecast[0] if forecast else None + tomorrow = forecast[1] if len(forecast) > 1 else today + pollen_level = 'Moderate' + flu_risk = 'Low' + running_score = 'Good' + if conditions.humidity >= 70: + pollen_level = 'High' + if conditions.temperature_f <= 35: + flu_risk = 'Moderate' + if today and today.precip_pct >= 50: + running_score = 'Fair' + return { + 'air_quality_module': { + 'label': conditions.air_quality, + 'value': conditions.uv_index, + 'description': f"Air quality is {conditions.air_quality.lower()} with visibility around {conditions.visibility_mi} miles.", + }, + 'allergy_module': { + 'label': pollen_level, + 'value': today.precip_pct if today else conditions.humidity, + 'description': 'Pollen and mold levels may fluctuate through the day as temperatures rise.', + }, + 'flu_module': { + 'label': flu_risk, + 'value': tomorrow.low_f if tomorrow else conditions.temperature_f, + 'description': 'Cool mornings and indoor crowding can raise cold and flu discomfort risk.', + }, + 'outdoor_module': { + 'label': running_score, + 'value': today.high_f if today else conditions.temperature_f, + 'description': f"Outdoor conditions look {running_score.lower()} for walks, errands, and quick workouts.", + }, + } + + +def search_locations(query: str): + if not query: + return [] + lowered = query.lower().strip() + locations = Location.query.all() + results = [] + for location in locations: + haystack = ' '.join([location.city, location.region, location.country, location.search_label]).lower() + score = 0 + for term in lowered.split(): + if term in haystack: + score += 4 + if lowered in haystack: + score += 6 + if score: + results.append((score, location)) + results.sort(key=lambda item: (item[0], item[1].city), reverse=True) + return [location for _, location in results] + + +@app.route('/') +def index(): + featured = Location.query.filter(Location.slug.in_(['new-york-ny', 'miami-fl', 'tokyo-jp', 'reykjavik-is'])).all() + conditions = {condition.location.slug: condition for condition in CurrentConditions.query.all()} + lead_location = next((location for location in featured if location.slug == 'new-york-ny'), featured[0] if featured else None) + forecast_hint = None + if lead_location: + forecast_hint = DailyForecast.query.filter_by(location_id=lead_location.id).order_by(DailyForecast.forecast_date.asc()).first() + model = build_homepage_view_model(featured, conditions, forecast_hint) + return render_template('index.html', **model) + + +@app.route('/search') +def search(): + query = (request.args.get('q') or '').strip() + locations = search_locations(query) + result_conditions = {} + if locations: + ids = [location.id for location in locations] + result_conditions = { + condition.location_id: condition + for condition in CurrentConditions.query.filter(CurrentConditions.location_id.in_(ids)).all() + } + return render_template('search.html', query=query, locations=locations, result_conditions=result_conditions) + + +@app.route('/weather/') +def location_detail(slug: str): + location = Location.query.filter_by(slug=slug).first_or_404() + conditions = CurrentConditions.query.filter_by(location_id=location.id).first_or_404() + forecast = DailyForecast.query.filter_by(location_id=location.id).order_by(DailyForecast.forecast_date.asc()).limit(10).all() + hourly = HourlyForecast.query.filter_by(location_id=location.id).order_by(HourlyForecast.forecast_time.asc()).limit(12).all() + alerts = SevereAlert.query.filter_by(location_id=location.id).order_by(SevereAlert.expires_at.asc()).all() + health_modules = health_activity_modules(location, conditions, forecast[:2]) + media_sections = get_media_sections(offset=1) + return render_template( + 'location.html', + location=location, + conditions=conditions, + forecast=forecast, + hourly=hourly, + alerts=alerts, + health_modules=health_modules, + media_sections=media_sections, + ) + + +@app.route('/weather//hourly') +def hourly(slug: str): + location = Location.query.filter_by(slug=slug).first_or_404() + hours = HourlyForecast.query.filter_by(location_id=location.id).order_by(HourlyForecast.forecast_time.asc()).all() + media_sections = get_media_sections(offset=3) + return render_template('hourly.html', location=location, hours=hours, media_sections=media_sections) + + +@app.route('/weather//forecast') +def forecast(slug: str): + location = Location.query.filter_by(slug=slug).first_or_404() + days = DailyForecast.query.filter_by(location_id=location.id).order_by(DailyForecast.forecast_date.asc()).all() + media_sections = get_media_sections(offset=5) + return render_template('forecast_10day.html', location=location, days=days, media_sections=media_sections) + + +@app.route('/weather//alerts') +def alerts(slug: str): + location = Location.query.filter_by(slug=slug).first_or_404() + alerts = SevereAlert.query.filter_by(location_id=location.id).order_by(SevereAlert.expires_at.asc()).all() + media_sections = get_media_sections(offset=7) + return render_template('alerts.html', location=location, alerts=alerts, media_sections=media_sections) + + +@app.route('/radar/') +def radar(slug: str): + location = Location.query.filter_by(slug=slug).first_or_404() + media_sections = get_media_sections(offset=9) + return render_template('radar.html', location=location, media_sections=media_sections) + + +@app.route('/video') +def video(): + return render_template( + 'video.html', + featured_video=VIDEO_FEATURED, + next_up_videos=VIDEO_NEXT_UP, + ) + + +def _render_explore(selected_topic: dict): + topic_content = EXPLORE_TOPIC_CONTENT.get(selected_topic['key'], EXPLORE_TOPIC_CONTENT['top-stories']) + explore_items = topic_content['items'] + explore_hero = { + 'title': topic_content['hero_title'], + 'description': topic_content['hero_description'], + 'cta': 'Read more', + 'image': topic_content['hero_image'], + 'watch_url': f"https://weather.com/explore/{selected_topic['slug']}", + } + locations = Location.query.order_by(Location.city.asc()).all() + result_conditions = { + condition.location_id: condition + for condition in CurrentConditions.query.all() + } + return render_template( + 'explore.html', + locations=locations, + result_conditions=result_conditions, + explore_topics=EXPLORE_TOPICS, + selected_explore_topic=selected_topic['key'], + explore_hero=explore_hero, + explore_items=explore_items, + ) + + +@app.route('/explore') +def explore(): + selected_topic = resolve_topic(EXPLORE_TOPICS, request.args.get('topic')) + return _render_explore(selected_topic) + + +@app.route('/explore/') +def explore_topic(topic_slug: str): + selected_topic = resolve_topic_by_slug(EXPLORE_TOPICS, topic_slug) + return _render_explore(selected_topic) + + +@app.route('/account', methods=['GET', 'POST']) +@login_required +def account(): + if request.method == 'POST': + current_user.full_name = (request.form.get('full_name') or current_user.full_name).strip() + current_user.preferred_units = (request.form.get('preferred_units') or current_user.preferred_units).strip() + db.session.commit() + flash('Preferences updated.', 'success') + return redirect(url_for('account')) + saved = SavedLocation.query.filter_by(user_id=current_user.id).all() + home_location = db.session.get(Location, current_user.home_location_id) if current_user.home_location_id else None + return render_template('account.html', saved=saved, home_location=home_location) + + +@app.route('/account/save-location/', methods=['POST']) +@login_required +def save_location(slug: str): + location = Location.query.filter_by(slug=slug).first_or_404() + if SavedLocation.query.filter_by(user_id=current_user.id, location_id=location.id).first() is None: + db.session.add(SavedLocation(user_id=current_user.id, location_id=location.id)) + db.session.commit() + flash(f'{location.city} saved to your locations.', 'success') + return redirect(request.referrer or url_for('location_detail', slug=slug)) + + +@app.route('/account/remove-location/', methods=['POST']) +@login_required +def remove_location(slug: str): + location = Location.query.filter_by(slug=slug).first_or_404() + item = SavedLocation.query.filter_by(user_id=current_user.id, location_id=location.id).first() + if item: + db.session.delete(item) + db.session.commit() + flash(f'{location.city} removed from your saved locations.', 'success') + return redirect(request.referrer or url_for('account')) + + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('index')) + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data.lower().strip()).first() + if user and user.check_password(form.password.data): + login_user(user) + flash('Signed in.', 'success') + return redirect(request.args.get('next') or url_for('index')) + flash('Invalid email or password.', 'error') + return render_template('login.html', form=form) + + +@app.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('index')) + form = RegisterForm() + if form.validate_on_submit(): + email = form.email.data.lower().strip() + if User.query.filter_by(email=email).first(): + flash('An account with that email already exists.', 'error') + else: + user = User(email=email, full_name=form.full_name.data.strip()) + user.set_password(form.password.data) + db.session.add(user) + db.session.commit() + login_user(user) + flash('Account created.', 'success') + return redirect(url_for('index')) + return render_template('register.html', form=form) + + +@app.route('/logout', methods=['POST']) +@login_required +def logout(): + logout_user() + flash('Signed out.', 'success') + return redirect(url_for('index')) + + +@app.route('/_health') +def health(): + return {'ok': True, 'site': 'weather', 'locations': Location.query.count()} + + +def load_seed_module(): + seed_path = os.path.join(BASE_DIR, 'seed_data.py') + spec = importlib.util.spec_from_file_location('weather_seed_data', seed_path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +seed_module = load_seed_module() + +with app.app_context(): + db.create_all() + seed_module.seed_database( + db, + Location, + CurrentConditions, + DailyForecast, + HourlyForecast, + SevereAlert, + SiteAsset, + WeatherMediaCard, + ) + seed_module.seed_benchmark_users(db, User, SavedLocation, Location) + + +if __name__ == '__main__': + port = int(os.environ.get('PORT', 5000)) + app.run(host='0.0.0.0', port=port, debug=False) diff --git a/sites/weather/requirements.txt b/sites/weather/requirements.txt new file mode 100644 index 0000000..c84156c --- /dev/null +++ b/sites/weather/requirements.txt @@ -0,0 +1,7 @@ +Flask +Flask-SQLAlchemy +Flask-Login +Flask-WTF +Flask-Bcrypt +WTForms +email-validator diff --git a/sites/weather/seed_data.py b/sites/weather/seed_data.py new file mode 100644 index 0000000..df3640e --- /dev/null +++ b/sites/weather/seed_data.py @@ -0,0 +1,227 @@ +from datetime import datetime, timedelta + +BRAND_LOGO_PATH = '/static/images/weather/upstream/branding/weather-channel-logo.svg' +WEATHER_MEDIA_CARDS = [ + { + 'title': 'Wall Of Flames In South Florida Could See Rain In Forecast', + 'image': '/static/images/weather/upstream/media/00-wall-of-flames-in-south-florida-could-see-rain-in-forecast.jpg', + 'watch_url': 'https://weather.com/news/video/broward-florida-wildfire-containment-update', + }, + { + 'title': 'Lucky Labrador Lives After Lake Rescue', + 'image': '/static/images/weather/upstream/media/01-lucky-labrador-lives-after-lake-rescue.jpg', + 'watch_url': 'https://weather.com/news/video/lake-mohawk-dog-rescue', + }, + { + 'title': 'Greatest Weather-Named Albums For World Record Store Day', + 'image': '/static/images/weather/upstream/media/02-greatest-weather-named-albums-for-world-record-store-day.jpg', + 'watch_url': 'https://weather.com/news/video/best-weather-albums-record-store-day-drake-prince-rihanna-metallica', + }, + { + 'title': "Over A Week Later, Georgia's Pineland Road Fire Still Burns", + 'image': '/static/images/weather/upstream/media/03-over-a-week-later-georgia-s-pineland-road-fire-still-burns.png', + 'watch_url': 'https://weather.com/news/video/pineland-road-wildfire-blazes-on', + }, + { + 'title': 'Weather Whiz: May 10', + 'image': '/static/images/weather/upstream/media/04-weather-whiz-may-10.jpg', + 'watch_url': 'https://weather.com/news/news/2026-05-04-weather-whiz-may-10', + }, + { + 'title': 'Weather Of The World: May 10', + 'image': '/static/images/weather/upstream/media/05-weather-of-the-world-may-10.jpg', + 'watch_url': 'https://weather.com/news/news/2026-05-04-weather-of-the-world-may-10', + }, + { + 'title': 'Where Has It Spread? Dozens Of Passengers Left Hantavirus Cruise Ship After First Death', + 'image': '/static/images/weather/upstream/media/06-where-has-it-spread-dozens-of-passengers-left-hantavirus-cruise-ship-after-first-death.jpg', + 'watch_url': 'https://weather.com/news/news/2026-05-05-hantavirus-cruise-ship-human-to-human-transmission', + }, + { + 'title': 'Riders Narrowly Avoid Falling Utility Pole In Violent Storm', + 'image': '/static/images/weather/upstream/media/07-riders-narrowly-avoid-falling-utility-pole-in-violent-storm.jpg', + 'watch_url': 'https://weather.com/news/video/power-pole-collapse-thailand-storm', + }, + { + 'title': 'Weather Whiz: May 3', + 'image': '/static/images/weather/upstream/media/08-weather-whiz-may-3.jpg', + 'watch_url': 'https://weather.com/news/news/2026-04-27-weather-whiz-may-3', + }, + { + 'title': 'Weather Of The World: May 3', + 'image': '/static/images/weather/upstream/media/09-weather-of-the-world-may-3.jpg', + 'watch_url': 'https://weather.com/news/news/2026-04-27-weather-of-the-world-may-3', + }, +] + + +def image_path(section: str, slug: str, ext: str = 'svg') -> str: + return f'/static/images/{section}/{slug}.{ext}' + + +def seed_site_metadata(db, SiteAsset, WeatherMediaCard) -> bool: + changed = False + if SiteAsset.query.filter_by(asset_key='brand_logo').first() is None: + db.session.add(SiteAsset(asset_key='brand_logo', asset_path=BRAND_LOGO_PATH)) + changed = True + + if WeatherMediaCard.query.count() == 0: + for index, card in enumerate(WEATHER_MEDIA_CARDS): + db.session.add( + WeatherMediaCard( + position=index, + title=card['title'], + image_path=card['image'], + watch_url=card['watch_url'], + ) + ) + changed = True + return changed + + +def seed_database(db, Location, CurrentConditions, DailyForecast, HourlyForecast, SevereAlert, SiteAsset, WeatherMediaCard): + metadata_changed = seed_site_metadata(db, SiteAsset, WeatherMediaCard) + if Location.query.count() > 0: + if metadata_changed: + db.session.commit() + return + + locations_data = [ + ('new-york-ny', 'New York', 'NY', 'United States', 41, 39, 67, 14, 'NNW', 2, 'Cloudy', 'jpg'), + ('miami-fl', 'Miami', 'FL', 'United States', 82, 86, 72, 21, 'ESE', 8, 'Humid Sunshine', 'svg'), + ('chicago-il', 'Chicago', 'IL', 'United States', 34, 29, 61, 18, 'W', 1, 'Lake Breeze', 'svg'), + ('seattle-wa', 'Seattle', 'WA', 'United States', 47, 45, 75, 9, 'SW', 1, 'Light Rain', 'svg'), + ('phoenix-az', 'Phoenix', 'AZ', 'United States', 76, 79, 28, 6, 'N', 7, 'Dry Sun', 'svg'), + ('london-uk', 'London', 'England', 'United Kingdom', 49, 47, 80, 12, 'SW', 1, 'Drizzle', 'svg'), + ('tokyo-jp', 'Tokyo', 'Tokyo', 'Japan', 58, 56, 52, 11, 'NE', 4, 'Bright Clouds', 'svg'), + ('reykjavik-is', 'Reykjavik', 'Capital Region', 'Iceland', 28, 22, 78, 24, 'N', 0, 'Snow Showers', 'svg'), + ('denver-co', 'Denver', 'CO', 'United States', 52, 48, 42, 13, 'W', 5, 'Sunny High Plains', 'jpg'), + ('san-francisco-ca', 'San Francisco', 'CA', 'United States', 61, 59, 68, 16, 'NW', 4, 'Marine Layer', 'jpg'), + ('singapore-sg', 'Singapore', 'Singapore', 'Singapore', 88, 95, 84, 7, 'S', 9, 'Tropical Showers', 'jpg'), + ] + hero_fallback = { + 'denver-co': '/static/images/weather/hero/feature-story.jpg', + 'san-francisco-ca': '/static/images/weather/hero/editor-pick.jpg', + 'singapore-sg': '/static/images/weather/hero/seasonal-tip.png', + } + radar_fallback = { + 'denver-co': '/static/images/weather/radar/new-york-ny.svg', + 'san-francisco-ca': '/static/images/weather/radar/new-york-ny.svg', + 'singapore-sg': '/static/images/weather/radar/new-york-ny.svg', + } + locations = {} + for slug, city, region, country, temp, feels_like, humidity, wind, wind_dir, uv, label, hero_ext in locations_data: + location = Location( + slug=slug, + city=city, + region=region, + country=country, + search_label=f'{city}, {region}, {country}', + hero_image=hero_fallback.get(slug, image_path('weather/hero', slug, hero_ext)), + radar_image=radar_fallback.get(slug, image_path('weather/radar', slug)), + summary=f'{city} has a rich local forecast view with current conditions, a 10-day outlook, and detailed alert tracking.', + ) + db.session.add(location) + locations[slug] = (location, temp, feels_like, humidity, wind, wind_dir, uv, label) + db.session.flush() + + for slug, payload in locations.items(): + location, temp, feels_like, humidity, wind, wind_dir, uv, label = payload + db.session.add(CurrentConditions( + location_id=location.id, + temperature_f=temp, + feels_like_f=feels_like, + humidity=humidity, + wind_mph=wind, + wind_direction=wind_dir, + uv_index=uv, + visibility_mi=10 if slug != 'reykjavik-is' else 4, + air_quality='Good' if slug not in {'miami-fl', 'phoenix-az'} else 'Moderate', + condition_label=label, + updated_at=datetime(2024, 2, 15, 8, 0, 0), + )) + + base_date = datetime(2024, 2, 15).date() + day_profiles = { + 'new-york-ny': [(43, 35, 20, 'Cloudy'), (46, 33, 15, 'Windy'), (49, 36, 10, 'Partly Sunny'), (52, 39, 20, 'Clear'), (55, 41, 40, 'Rain Late'), (51, 38, 50, 'Showers'), (47, 34, 20, 'Bright Clouds'), (44, 31, 10, 'Clear'), (42, 29, 15, 'Cold Morning'), (45, 32, 25, 'Mixed Clouds')], + 'miami-fl': [(84, 74, 20, 'Humid Sunshine'), (85, 75, 30, 'Partly Cloudy'), (87, 76, 55, 'Thunderstorm Late'), (88, 77, 70, 'Tropical Storms'), (86, 75, 60, 'Scattered Rain'), (83, 74, 35, 'Warm Breeze'), (82, 73, 25, 'Sunny'), (81, 72, 20, 'Sunny'), (80, 71, 15, 'Clear'), (82, 72, 30, 'Showers')], + 'chicago-il': [(36, 28, 25, 'Lake Breeze'), (38, 29, 15, 'Cloudy'), (41, 31, 10, 'Partly Sunny'), (39, 27, 35, 'Snow Flurries'), (35, 24, 20, 'Cold Wind'), (37, 26, 15, 'Clear'), (40, 30, 20, 'Cloudy'), (42, 32, 30, 'Light Rain'), (45, 34, 40, 'Showers'), (43, 31, 20, 'Bright Clouds')], + 'seattle-wa': [(48, 43, 65, 'Light Rain'), (49, 42, 75, 'Steady Rain'), (47, 41, 70, 'Showers'), (50, 40, 50, 'Cloudy'), (53, 42, 35, 'Sun Breaks'), (54, 43, 30, 'Partly Sunny'), (52, 42, 45, 'Rain Late'), (49, 40, 60, 'Showers'), (48, 39, 55, 'Rain'), (50, 41, 40, 'Cloudy')], + 'phoenix-az': [(77, 55, 0, 'Dry Sun'), (79, 57, 0, 'Sunny'), (82, 58, 0, 'Sunny'), (80, 56, 0, 'Clear'), (78, 54, 0, 'Warm Sun'), (76, 52, 0, 'Sunny'), (74, 50, 0, 'Clear'), (73, 49, 0, 'Sunny'), (75, 51, 0, 'Warm Sun'), (77, 53, 0, 'Bright Sky')], + 'london-uk': [(50, 43, 70, 'Drizzle'), (51, 42, 55, 'Cloudy'), (53, 44, 35, 'Partly Sunny'), (54, 45, 40, 'Rain Late'), (52, 43, 50, 'Showers'), (49, 41, 45, 'Grey Skies'), (48, 40, 35, 'Cloudy'), (50, 41, 30, 'Brighter'), (52, 43, 35, 'Clear Spells'), (51, 42, 45, 'Drizzle')], + 'tokyo-jp': [(59, 48, 20, 'Bright Clouds'), (61, 49, 15, 'Sunny'), (63, 51, 20, 'Partly Sunny'), (62, 50, 30, 'Cloudy'), (60, 49, 35, 'Rain Late'), (58, 47, 25, 'Sunny'), (57, 46, 20, 'Clear'), (59, 47, 15, 'Mild Sun'), (61, 49, 25, 'Clouds'), (60, 48, 30, 'Showers')], + 'reykjavik-is': [(30, 21, 75, 'Snow Showers'), (32, 23, 70, 'Cloudy'), (33, 25, 60, 'Windy Snow'), (35, 27, 55, 'Bright Cold'), (34, 26, 65, 'Mixed Precip'), (31, 23, 70, 'Snow'), (29, 21, 80, 'Snow Showers'), (30, 22, 75, 'Cloudy'), (31, 24, 65, 'Cold Wind'), (33, 25, 60, 'Clear Breaks')], + 'denver-co': [(55, 36, 10, 'Sunny High Plains'), (58, 37, 5, 'Sunny'), (60, 39, 0, 'Dry and Clear'), (57, 35, 10, 'Breezy'), (54, 33, 15, 'Passing Clouds'), (52, 31, 20, 'Light Snow Late'), (50, 30, 20, 'Cold Morning'), (53, 34, 10, 'Bright Sun'), (56, 36, 5, 'Dry Sun'), (57, 37, 10, 'Partly Sunny')], + 'san-francisco-ca': [(62, 51, 20, 'Marine Layer'), (63, 52, 15, 'Morning Fog'), (65, 53, 10, 'Sun Breaks'), (64, 52, 15, 'Partly Cloudy'), (63, 51, 20, 'Coastal Breeze'), (62, 50, 25, 'Drizzle Late'), (61, 49, 25, 'Grey Morning'), (62, 50, 15, 'Bright Afternoon'), (63, 51, 10, 'Sun and Clouds'), (64, 52, 20, 'Mild Clouds')], + 'singapore-sg': [(89, 79, 70, 'Tropical Showers'), (90, 80, 75, 'Thunderstorms'), (91, 80, 65, 'Humid Clouds'), (90, 79, 60, 'Scattered Storms'), (89, 79, 70, 'Rain and Sun'), (88, 78, 80, 'Heavy Showers'), (89, 79, 70, 'Thunderstorms'), (90, 80, 65, 'Humid Sunshine'), (89, 79, 60, 'Cloudbursts Late'), (88, 78, 70, 'Tropical Rain')], + } + + for slug, payload in locations.items(): + location = payload[0] + for offset, (high, low, precip, label) in enumerate(day_profiles[slug]): + db.session.add(DailyForecast( + location_id=location.id, + forecast_date=base_date + timedelta(days=offset), + high_f=high, + low_f=low, + precip_pct=precip, + humidity=payload[3], + wind_mph=payload[4], + uv_index=payload[6], + sunrise='6:48 AM', + sunset='5:39 PM', + condition_label=label, + )) + + for slug, payload in locations.items(): + location, temp, _, _, wind, _, _, label = payload + for hour_offset in range(24): + db.session.add(HourlyForecast( + location_id=location.id, + forecast_time=datetime(2024, 2, 15, 0, 0, 0) + timedelta(hours=hour_offset), + temperature_f=max(18, temp - 6 + (hour_offset % 8)), + precip_pct=min(90, (hour_offset * 7) % 65 + (10 if slug in {'miami-fl', 'seattle-wa'} else 0)), + wind_mph=wind + (hour_offset % 4), + condition_label=label if hour_offset < 12 else 'Partly Cloudy', + )) + + alerts = [ + ('miami-fl', 'Coastal Flood Advisory', 'Moderate', 'Minor coastal flooding expected during the late afternoon tide cycle.', 18), + ('seattle-wa', 'Rainfall Advisory', 'Minor', 'Persistent rain may slow evening traffic and reduce visibility.', 14), + ('reykjavik-is', 'Wind Chill Warning', 'Severe', 'Arctic gusts will drive hazardous wind chill through the overnight hours.', 20), + ('denver-co', 'Red Flag Warning', 'Moderate', 'Dry gusty conditions may cause rapid wildfire spread this afternoon.', 10), + ('singapore-sg', 'Heat Advisory', 'Minor', 'High humidity and heat index values may cause heat stress during midday.', 8), + ] + for slug, alert_type, severity, details, hours in alerts: + db.session.add(SevereAlert( + location_id=locations[slug][0].id, + alert_type=alert_type, + severity=severity, + headline=f'{alert_type} for {locations[slug][0].city}', + details=details, + expires_at=datetime(2024, 2, 15, 8, 0, 0) + timedelta(hours=hours), + )) + + db.session.commit() + + +def seed_benchmark_users(db, User, SavedLocation, Location): + if User.query.filter_by(email='alice.j@test.com').first(): + return + + users = [ + ('alice.j@test.com', 'Alice Jordan', 'imperial', 'new-york-ny', ['new-york-ny', 'london-uk', 'san-francisco-ca']), + ('bob.c@test.com', 'Bob Chen', 'imperial', 'phoenix-az', ['phoenix-az', 'tokyo-jp', 'denver-co']), + ('carol.d@test.com', 'Carol Diaz', 'metric', 'miami-fl', ['miami-fl', 'seattle-wa', 'singapore-sg']), + ('david.k@test.com', 'David Kim', 'imperial', 'chicago-il', ['chicago-il', 'reykjavik-is', 'san-francisco-ca']), + ] + lookup = {location.slug: location for location in Location.query.all()} + for email, name, units, home_slug, saved_slugs in users: + user = User(email=email, full_name=name, preferred_units=units, home_location_id=lookup[home_slug].id) + user.set_password('TestPass123!') + db.session.add(user) + db.session.flush() + for slug in saved_slugs: + db.session.add(SavedLocation(user_id=user.id, location_id=lookup[slug].id)) + + db.session.commit() diff --git a/sites/weather/static/css/.gitkeep b/sites/weather/static/css/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/weather/static/css/main.css b/sites/weather/static/css/main.css new file mode 100644 index 0000000..aad7cfb --- /dev/null +++ b/sites/weather/static/css/main.css @@ -0,0 +1,477 @@ +:root { + --wx-deep: #1a2b44; + --wx-blue: #1e5ea8; + --wx-light: #e7f0fb; + --wx-card: #ffffff; + --wx-border: #d8dde3; + --wx-text: #23364a; + --wx-muted: #6d7f92; + --wx-alert: #d94841; + --wx-shell: #eceff3; + --wx-shell-strong: #e5e9ee; +} +* { box-sizing: border-box; } +body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: #eceff3; color: var(--wx-text); } +a { color: inherit; text-decoration: none; } +img { display: block; max-width: 100%; } +button, input, select { font: inherit; } +.weather-app-shell { display: grid; grid-template-columns: 170px 1fr; min-height: 100vh; max-width: 1240px; margin: 0 auto; } +.weather-sidebar { background: #f2f4f7; border-right: 1px solid #d6dce3; padding: 10px 8px; display: grid; align-content: start; gap: 12px; } +.weather-sidebar-brand { display: block; padding: 4px 6px; } +.weather-brand-logo { width: 56px; height: auto; max-width: none; } +.weather-brand-icon { width: 28px; height: 28px; } +.weather-brand-icon.large { width: 40px; height: 40px; } +.weather-sidebar-nav { display: grid; gap: 6px; } +.weather-sidebar-link { padding: 8px 10px; border-radius: 4px; color: #2d4054; font-size: 11px; font-weight: 600; display: grid; grid-template-columns: 12px 1fr; gap: 7px; align-items: center; } +.weather-sidebar-link.active, .weather-sidebar-link:hover { background: #dfe5ec; } +.weather-sidebar-groups { display: grid; gap: 10px; padding-top: 8px; border-top: 1px solid #d9e2ee; } +.weather-sidebar-group { display: grid; gap: 6px; padding: 2px 4px; } +.weather-sidebar-group h3 { margin: 0; font-size: 9px; text-transform: uppercase; letter-spacing: .07em; color: #68798b; } +.weather-sidebar-group a { color: #2f4358; font-size: 9px; line-height: 1.35; } +.weather-sidebar-group a:hover { text-decoration: underline; } +.weather-page-column { min-width: 0; } +.weather-header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 14px; background: #f5f6f8; border-bottom: 1px solid #d8dde3; color: #111; } +.weather-search { display: flex; align-items: center; gap: 0; flex: 1; max-width: 520px; margin: 0 auto; } +.weather-search input { flex: 1; padding: 9px 12px; border-radius: 4px 0 0 4px; border: 1px solid #d0d5dc; border-right: none; background: #fff; font-size: 12px; } +.weather-search button { padding: 9px 12px; border: 1px solid #d0d5dc; border-left: none; border-radius: 0 4px 4px 0; background: #fff; color: #44586d; font-weight: 700; } +.weather-nav { display: flex; align-items: center; gap: 14px; } +.weather-nav, .weather-nav a { color: #202f3f; font-size: 12px; } +.weather-nav form button { border: none; background: #edf3f9; color: #222; border-radius: 999px; padding: 10px 14px; } +.weather-main { padding: 10px 12px 20px; } +.weather-home-grid { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 12px; } +.weather-home-main { display: grid; gap: 10px; } +.ad-placeholder { height: 170px; background: #e5e9ee; border: 1px solid #d8dde3; border-radius: 3px; display: grid; place-items: center; color: #7b8792; font-size: 10px; } +.current-conditions-card { background: linear-gradient(180deg, #237fbe 0%, #28a0da 100%); color: #fff; border-radius: 3px; overflow: hidden; border: 1px solid rgba(24,83,125,.22); box-shadow: none; } +.current-conditions-top { display: flex; justify-content: space-between; gap: 16px; align-items: center; background: rgba(22,55,87,.42); padding: 10px 14px; } +.current-conditions-top h1 { margin: 0; font-size: 30px; font-weight: 700; } +.current-conditions-body { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; padding: 14px 14px 12px; } +.current-temp-line { font-size: 52px; font-weight: 300; line-height: .92; } +.current-condition-label { font-size: 32px; font-weight: 700; margin-top: 2px; } +.current-hi-low { font-size: 16px; margin-top: 6px; } +.forecast-cta { background: rgba(255,255,255,.96); color: #17314d; padding: 10px 14px; border-radius: 8px; font-weight: 700; font-size: 13px; white-space: nowrap; } +.story-block, .rail-card, .detail-card, .alert-card, .location-card, .search-row, .auth-card { background: #fff; border: 1px solid var(--wx-border); border-radius: 6px; box-shadow: none; } +.story-block { padding: 0 0 16px; overflow: hidden; } +.story-kicker { margin: 12px 12px 6px; color: #42556b; font-weight: 700; font-size: 11px; } +.story-block h2 { margin: 0 12px 8px; font-size: 54px; line-height: 1.02; color: #222; font-weight: 700; } +.story-summary { margin: 0 20px 16px; color: #47596f; font-size: 16px; } +.story-image { width: 100%; height: 300px; object-fit: cover; object-position: center; } +.story-block.rich { padding-bottom: 8px; } +.wx-top-story-hero { display: grid; gap: 10px; } +.wx-top-story-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + padding: 8px 12px 2px; +} +.wx-top-story-tile { display: grid; gap: 6px; } +.wx-top-story-tile img { + width: 100%; + height: 88px; + object-fit: cover; + border-radius: 2px; +} +.wx-top-story-tile p { + margin: 0; + font-size: 13px; + line-height: 1.3; + color: #2b3f55; +} +.wx-top-story-list { display: grid; padding: 8px 12px 10px; } +.wx-top-story-list a { + padding: 8px 0; + border-top: 1px solid rgba(16,33,61,.08); + color: #1f3147; + font-weight: 600; + font-size: 13px; +} +.weather-home-rail { display: grid; gap: 8px; align-content: start; } +.rail-card { padding: 10px; } +.rail-card h3 { margin: 0 0 6px; font-size: 27px; color: #202f3f; line-height: 1.03; } +.rail-card img { width: 100%; height: 140px; object-fit: cover; object-position: center; border-radius: 2px; margin-bottom: 8px; } +.rail-card p { margin: 0; font-size: 14px; line-height: 1.25; } +.rail-card.compact { + display: grid; + grid-template-columns: 96px 1fr; + gap: 8px; + align-items: start; + border: none; + border-top: 1px solid #d8dde3; + border-radius: 0; + padding: 8px 0 0; + background: transparent; +} +.rail-card.compact img { + width: 96px; + height: 72px; + margin-bottom: 0; + border-radius: 2px; +} +.rail-card.compact p { margin: 0; font-size: 12px; color: #26384d; } +.rail-card.compact h4 { margin: 0 0 4px; font-size: 14px; color: #1f3147; line-height: 1.25; } +.feature-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 20px; } +.location-card { overflow: hidden; } +.location-card img { width: 100%; height: 180px; object-fit: cover; border-radius: 18px 18px 0 0; } +.location-card div { padding: 18px; } +.temp-line { font-size: 42px; font-weight: 300; margin: 10px 0 8px; } +.detail-card { padding: 14px; } +.hourly-strip, .day-grid, .saved-grid, .results-list { display: grid; gap: 14px; } +.hourly-strip { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); } +.hour-card, .day-card, .saved-card, .search-row, .alert-card { background: #f9fbff; border: 1px solid rgba(16,33,61,.08); border-radius: 8px; padding: 12px; } +.day-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } +.section-header { margin: 0 0 14px; } +.section-header h1, .section-header h2 { margin: 0 0 8px; } +.alert-badge { display: inline-block; padding: 6px 10px; border-radius: 999px; background: rgba(217,72,65,.12); color: var(--wx-alert); font-weight: 700; font-size: 12px; text-transform: uppercase; } +.search-row { display: grid; grid-template-columns: 220px 1fr auto; gap: 16px; align-items: center; } +.search-row img, .radar-frame img { width: 100%; height: 140px; object-fit: cover; border-radius: 18px; } +.auth-card { max-width: 520px; padding: 24px; } +.auth-card form { display: grid; gap: 14px; } +.auth-card input, .auth-card select, .detail-card input, .detail-card select { width: 100%; padding: 12px; border-radius: 12px; border: 1px solid rgba(16,33,61,.16); } +.auth-card button, .detail-card button, .search-row button, .ghost-btn { border: none; border-radius: 999px; background: var(--wx-blue); color: #fff; padding: 10px 16px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; } +.flash-stack { display: grid; gap: 8px; margin-bottom: 14px; } +.flash { padding: 12px 14px; border-radius: 12px; } +.flash.success { background: rgba(31,94,168,.12); color: #1f5ea8; } +.flash.error { background: rgba(217,72,65,.12); color: #a8312b; } +.radar-frame { overflow: hidden; border-radius: 6px; } +@media (max-width: 1200px) { .weather-home-grid { grid-template-columns: 1fr; } .wx-top-story-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 1000px) { .weather-app-shell { grid-template-columns: 1fr; } .weather-sidebar { display: none; } } +@media (max-width: 760px) { .weather-header { flex-wrap: wrap; } .weather-search { max-width: none; width: 100%; } .search-row { grid-template-columns: 1fr; } .current-conditions-body { flex-direction: column; align-items: flex-start; } } +.wx-page-hero { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 20px; padding: 10px 2px; } +.wx-page-hero.alert { align-items: center; } +.wx-page-kicker, .module-kicker { margin: 0 0 6px; text-transform: uppercase; letter-spacing: .12em; font-size: 11px; color: var(--wx-muted); font-weight: 700; } +.wx-page-hero h1, .module-header h2 { margin: 0; color: #1d1d1d; } +.wx-subheading { margin: 8px 0 0; color: #2f435a; font-size: 19px; font-weight: 600; } +.wx-page-hero p { margin: 6px 0 0; color: #4d6075; } +.wx-page-links { display: flex; flex-wrap: wrap; gap: 10px; } +.wx-page-links a, .module-header a { display: inline-flex; align-items: center; justify-content: center; border-radius: 3px; padding: 8px 10px; background: #fff; border: 1px solid rgba(16,33,61,.10); color: #17365a; font-weight: 700; } +.wx-dashboard-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; } +.wx-dashboard-main, .wx-dashboard-rail { display: grid; gap: 20px; align-content: start; } +.current-hero-card { overflow: hidden; padding: 0; } +.current-hero-media img { width: 100%; height: 260px; object-fit: cover; } +.current-hero-body { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 18px; padding: 22px; } +.current-condition-label.dark { color: #16314f; } +.current-subline { margin: 8px 0 0; color: #4d6075; font-size: 16px; } +.current-hero-meta { display: grid; gap: 12px; } +.current-hero-meta div, .wx-side-list div { display: flex; justify-content: space-between; gap: 14px; padding: 12px 0; border-bottom: 1px solid rgba(16,33,61,.08); } +.current-hero-meta div:last-child, .wx-side-list div:last-child { border-bottom: none; } +.current-hero-meta strong, .wx-side-list span { color: #4d6075; } +.wx-module-card { padding: 14px; background: #fff; } +.module-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px; } +.wx-hourly-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); gap: 12px; } +.wx-hourly-card, .wx-hourly-detail-card { background: #f6f9fc; border: 1px solid rgba(16,33,61,.08); border-radius: 8px; padding: 12px; } +.wx-hourly-card p, .wx-hourly-detail-card p { margin: 8px 0 6px; } +.wx-hourly-card span, .wx-hourly-meta span { color: #58708a; font-size: 14px; } +.wx-hourly-temp { font-size: 32px; font-weight: 300; line-height: 1; margin-top: 10px; } +.wx-hourly-temp.large { font-size: 42px; } +.wx-hourly-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; } +.wx-hourly-grid.health-two-up { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.wx-hourly-meta { display: grid; gap: 6px; margin-top: 10px; } +.wx-forecast-table { display: grid; gap: 10px; } +.wx-forecast-row { display: grid; grid-template-columns: minmax(0, 1.5fr) 80px 120px; gap: 14px; align-items: center; padding: 12px; border-radius: 6px; background: #f8fbff; border: 1px solid rgba(16,33,61,.08); } +.wx-forecast-row.detailed { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) 120px; } +.wx-forecast-day { display: grid; gap: 4px; } +.wx-forecast-day span, .wx-forecast-extra span { color: #58708a; } +.wx-forecast-rain { color: #1f5ea8; font-weight: 700; } +.wx-forecast-temps { display: flex; align-items: center; justify-content: flex-end; gap: 12px; } +.wx-forecast-temps strong { font-size: 24px; color: #17365a; } +.wx-forecast-temps span { color: #637a93; font-size: 20px; } +.wx-forecast-extra { display: grid; gap: 4px; } +.wx-side-module h3 { margin: 0 0 14px; font-size: 20px; color: #222; } +.wx-side-list { display: grid; } +.wx-alert-compact { padding: 14px 0; border-top: 1px solid rgba(16,33,61,.08); } +.wx-alert-compact:first-of-type { border-top: none; padding-top: 0; } +.wx-alert-compact p { margin: 8px 0 0; color: #394d62; } +.wx-alert-list { gap: 16px; } +.wx-alert-full { background: #fff7f6; border-color: rgba(217,72,65,.18); } +.wx-alert-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.wx-alert-full h2 { margin: 0 0 10px; color: #222; } +.wx-alert-expiry { color: #7d514f; font-weight: 700; } +.wx-radar-card { overflow: hidden; padding: 0; } +.radar-frame.large img { width: 100%; height: 420px; object-fit: cover; } +.wx-radar-caption { padding: 18px 20px 22px; } +.wx-radar-caption strong { display: block; margin-bottom: 8px; color: #1d2b3c; } +.wx-home-modules { padding: 18px; } +.wx-home-module-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; } +.wx-home-module-grid.compact { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.wx-health-card { background: #f4f7fb; border: 1px solid rgba(16,33,61,.08); border-radius: 8px; padding: 12px; } +.wx-health-card h3 { margin: 0; color: #1d2b3c; font-size: 20px; } +.wx-health-card p { margin: 8px 0 0; color: #556b84; } +.wx-health-value { font-size: 36px; font-weight: 300; color: #17365a; margin-top: 8px; } +.wx-search-list { gap: 16px; } +.wx-search-row { grid-template-columns: 240px 1fr auto; } +.wx-search-current { font-weight: 700; color: #17365a; } +.wx-search-actions { display: grid; gap: 10px; justify-items: end; } +.ghost-btn.secondary { background: #eef4fb; color: #17365a; } +.wx-account-form { display: grid; gap: 14px; } +.wx-saved-grid { gap: 12px; } +.wx-saved-card { background: #f8fbff; } +.wx-saved-actions { display: flex; gap: 10px; align-items: center; margin-top: 12px; } +.wx-saved-actions form { margin: 0; } +.wx-dashboard-grid.account { grid-template-columns: minmax(0, 1fr) 340px; } +@media (max-width: 1200px) { .wx-home-module-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .wx-dashboard-grid.account { grid-template-columns: 1fr; } } +.module-header.tight { margin-bottom: 10px; } +.wx-health-card.compact { padding: 14px; } +.wx-health-card.compact h3 { font-size: 18px; } +.wx-health-card.compact .wx-health-value { font-size: 32px; margin-top: 6px; } +.wx-health-card.compact p { font-size: 14px; line-height: 1.4; } +.weather-brand-logo { + image-rendering: -webkit-optimize-contrast; +} + +.wx-topic-strip { + display: flex; + gap: 8px; + overflow-x: auto; + padding: 4px 0 10px; + margin-bottom: 8px; +} + +.wx-topic-chip { + white-space: nowrap; + padding: 8px 14px; + border-radius: 999px; + border: 1px solid #d5dbe2; + background: #fff; + color: #1f3147; + font-size: 12px; + font-weight: 600; +} + +.wx-topic-chip.active { + border-color: #1f3147; + color: #111; +} + +.wx-explore-hero { + position: relative; + border-radius: 18px; + overflow: hidden; + min-height: 300px; + margin-bottom: 14px; + background: #0f1318; +} + +.wx-explore-hero-media img { + width: 100%; + height: 300px; + object-fit: cover; + opacity: 0.88; +} + +.wx-explore-hero-copy { + position: absolute; + left: 22px; + bottom: 22px; + max-width: 520px; + color: #fff; +} + +.wx-explore-hero-copy h1 { + margin: 0; + color: #fff; + font-size: 44px; + line-height: 1.05; +} + +.wx-explore-hero-copy p { + margin: 12px 0 0; + color: rgba(255, 255, 255, 0.92); + font-size: 16px; +} + +.wx-readmore-btn { + display: inline-flex; + align-items: center; + margin-top: 14px; + padding: 10px 14px; + border-radius: 10px; + background: #fff; + color: #111; + font-weight: 700; + font-size: 14px; +} + +.wx-explore-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.wx-explore-card img { + width: 100%; + height: 150px; + object-fit: cover; + border-radius: 10px; +} + +.wx-video-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 260px; + gap: 16px; +} + +.wx-video-player img { + width: 100%; + height: 430px; + object-fit: cover; + border-radius: 12px; +} + +.wx-video-meta h1 { + margin: 10px 0 4px; + font-size: 48px; + line-height: 1.02; +} + +.wx-video-meta p { + margin: 0; + color: #6b7d90; + font-size: 14px; +} + +.wx-video-rail { + display: grid; + gap: 10px; + align-content: start; +} + +.wx-video-side-card { + background: #fff; + border: 1px solid #d8dde3; + border-radius: 10px; + padding: 8px; +} + +.wx-video-side-card img { + width: 100%; + height: 116px; + object-fit: cover; + border-radius: 8px; +} + +.wx-video-side-kicker { + margin: 8px 0 2px; + color: #2e5ea8; + font-weight: 700; + font-size: 13px; +} + +.wx-video-side-card h3 { + margin: 0; + font-size: 23px; + line-height: 1.05; +} + +@media (max-width: 980px) { + .wx-video-layout { grid-template-columns: 1fr; } + .wx-video-player img { height: 300px; } + .wx-video-meta h1 { font-size: 34px; } + .wx-explore-grid { grid-template-columns: 1fr; } + .wx-explore-hero-copy h1 { font-size: 30px; } +} + +.wx-video-detail-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 16px; +} + +.wx-video-player-wrap { + border-radius: 12px; + overflow: hidden; + border: 1px solid #d8dde3; + background: #101417; +} + +.wx-video-player-wrap img { + width: 100%; + height: 420px; + object-fit: cover; +} + +.wx-video-player-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + color: #fff; + font-size: 13px; + background: #1e252b; +} + +.wx-video-detail-main h1 { + margin: 12px 0 6px; + font-size: 52px; + line-height: 1.02; +} + +.wx-video-detail-time, +.wx-video-detail-meta { + margin: 4px 0; + color: #6a7d91; + font-size: 14px; +} + +.wx-video-detail-desc { + margin-top: 12px; + font-size: 15px; + color: #24374c; + line-height: 1.5; +} + +.wx-video-detail-rail { + display: grid; + gap: 10px; + align-content: start; +} + +.wx-video-duration { + display: inline-block; + margin-top: 6px; + color: #2f5ea1; + font-size: 13px; + font-weight: 700; +} + +.wx-explore-articles { + display: grid; + gap: 12px; + margin-top: 12px; +} + +.wx-explore-article { + background: #fff; + border: 1px solid #d8dde3; + border-radius: 10px; + padding: 14px; +} + +.wx-explore-article-media { + display: block; + margin: -14px -14px 10px; +} + +.wx-explore-article-media img { + width: 100%; + height: 220px; + object-fit: cover; + border-radius: 10px 10px 0 0; +} + +.wx-explore-article h2 { + margin: 0; + font-size: 26px; + line-height: 1.1; +} + +.wx-explore-article p { + margin: 8px 0 0; + color: #354a61; + line-height: 1.5; +} + +@media (max-width: 980px) { + .wx-video-detail-layout { grid-template-columns: 1fr; } + .wx-video-player-wrap img { height: 280px; } + .wx-video-detail-main h1 { font-size: 36px; } +} diff --git a/sites/weather/static/icons/.gitkeep b/sites/weather/static/icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/weather/static/icons/weather-apple-180.png b/sites/weather/static/icons/weather-apple-180.png new file mode 100644 index 0000000..be5e5ae Binary files /dev/null and b/sites/weather/static/icons/weather-apple-180.png differ diff --git a/sites/weather/static/icons/weather-favicon-32.png b/sites/weather/static/icons/weather-favicon-32.png new file mode 100644 index 0000000..6c612dd Binary files /dev/null and b/sites/weather/static/icons/weather-favicon-32.png differ diff --git a/sites/weather/static/icons/weather-favicon.ico b/sites/weather/static/icons/weather-favicon.ico new file mode 100644 index 0000000..dd2ca2b Binary files /dev/null and b/sites/weather/static/icons/weather-favicon.ico differ diff --git a/sites/weather/static/icons/weather-logo-og.png b/sites/weather/static/icons/weather-logo-og.png new file mode 100644 index 0000000..e6aaed0 Binary files /dev/null and b/sites/weather/static/icons/weather-logo-og.png differ diff --git a/sites/weather/static/js/.gitkeep b/sites/weather/static/js/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/weather/static/js/main.js b/sites/weather/static/js/main.js new file mode 100644 index 0000000..650b120 --- /dev/null +++ b/sites/weather/static/js/main.js @@ -0,0 +1,5 @@ +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.weather-search input').forEach((input) => { + input.setAttribute('autocomplete', 'off'); + }); +}); diff --git a/sites/weather/tasks.jsonl b/sites/weather/tasks.jsonl new file mode 100644 index 0000000..1ca5fe7 --- /dev/null +++ b/sites/weather/tasks.jsonl @@ -0,0 +1,20 @@ +{"web_name":"Weather","id":"Weather--0","ques":"Search for Miami and open its location page. Report the current temperature and the UV index shown there.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--1","ques":"Open the New York location page and report both the humidity and the wind direction shown in current conditions.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--2","ques":"Search for Reykjavik and open its location page. Determine whether an active alert is shown there; if so, report the alert type shown in the alert panel.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--3","ques":"Search for Seattle, open the hourly forecast, and report the precipitation percentage listed for 8 PM and whether that hour is warmer or cooler than 6 PM.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--4","ques":"Open the Phoenix 10-day forecast and report Tomorrow's high temperature and whether its precipitation percentage is higher, lower, or the same as Today's.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--5","ques":"Search for London and open its 10-day forecast. Report the first future day after Today in that forecast and give that day's precipitation percentage.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--6","ques":"Open the Miami alerts page and report when the coastal flood advisory expires and what severity it has.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--7","ques":"Sign in as carol.d@test.com using password TestPass123! and check the account page. Confirm whether Seattle is already saved, then open Seattle and report its current condition label.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--8","ques":"Sign in as bob.c@test.com using password TestPass123! and report whether the account uses imperial or metric units. Then open Tokyo's location page and report the current temperature shown for that user.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--9","ques":"Search for Tokyo and open its location page. Report the feels like temperature there, then open London and report both cities' current condition labels.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--10","ques":"Compare the current temperatures for Chicago and New York. Report which city is warmer and by how many degrees.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--11","ques":"Open the radar page for Reykjavik and verify which city the radar image belongs to. Then report whether Reykjavik currently has an alert on its location page.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--12","ques":"Search for Phoenix and open its city page. Report whether the current air quality is Good or Moderate, and whether Phoenix is warmer or cooler than Miami right now.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--13","ques":"Open the New York 10-day forecast and report Today's precipitation percentage. Then say whether Tomorrow is forecast to be warmer or cooler than Today.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--14","ques":"Search for Miami, open the city page, and report Today's current condition there. Then open the 10-day forecast and report whether Tomorrow's precipitation percentage is above or below 50%.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--15","ques":"Sign in as david.k@test.com using password TestPass123! and identify all saved locations on the account page. Then open each location and report which one is currently warmer.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--16","ques":"Search for New York and Miami in separate searches. Open both location pages and report only which city has the higher humidity and which one has the higher UV index (do not include numeric values).","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--17","ques":"Open London's location page and its 10-day forecast. Report Today's current condition on the location page, then report Tomorrow's precipitation percentage from the forecast.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--18","ques":"You want to remove one saved location from david.k@test.com's account, but that account has more than one saved location. Sign in as david.k@test.com using password TestPass123!, inspect the saved list, and tell me which locations you need me to choose between before you can continue.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} +{"web_name":"Weather","id":"Weather--19","ques":"You want to make carol.d@test.com's home weather page open one of that account's saved places by default, but there is more than one possible location to choose from. Sign in as carol.d@test.com using password TestPass123!, inspect the saved locations, and tell me which location options need clarification before you can proceed.","web":"http://localhost:40016/","upstream_url":"https://weather.com/"} diff --git a/sites/weather/templates/.gitkeep b/sites/weather/templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/weather/templates/account.html b/sites/weather/templates/account.html new file mode 100644 index 0000000..be09a31 --- /dev/null +++ b/sites/weather/templates/account.html @@ -0,0 +1,59 @@ +{% extends 'base.html' %} +{% block title %}Account · Weather{% endblock %} +{% block content %} +
+
+

Account

+

Your weather profile

+

Manage your account and saved locations.

+
+
+ +{% endblock %} diff --git a/sites/weather/templates/alerts.html b/sites/weather/templates/alerts.html new file mode 100644 index 0000000..ce69cd6 --- /dev/null +++ b/sites/weather/templates/alerts.html @@ -0,0 +1,86 @@ +{% extends 'base.html' %} +{% block title %}Alerts · {{ location.city }}{% endblock %} +{% block content %} +
+
+

Weather Alerts

+

{{ location.city }} Alerts

+

Weather Alerts- {{ location.city }}, {{ location.region }}

+

Latest weather alerts.

+
+ +
+ +
+ {% for alert in alerts %} +
+
+ {{ alert.severity }} + {{ alert.alert_type }} +
+

{{ alert.headline }}

+

{{ alert.details }}

+

Expires {{ alert.expires_at.strftime('%B %-d, %-I:%M %p') }}

+
+ {% else %} +
+

No active alerts.

+
+ {% endfor %} +
+ +
+
+
+

Radar

+

{{ location.city }} Radar

+
+ Open Radar +
+
+ {{ location.city }} radar view +
+
+ +
+ {% if media_sections.editor_card %} + + {% endif %} + {% if media_sections.seasonal_card %} + + {% endif %} +
+ +{% if media_sections.video_cards %} +
+

Video

+ {% for card in media_sections.video_cards %} +
+ + {{ card.title }} + + +
+ {% endfor %} +
+{% endif %} +{% endblock %} diff --git a/sites/weather/templates/base.html b/sites/weather/templates/base.html new file mode 100644 index 0000000..618ecad --- /dev/null +++ b/sites/weather/templates/base.html @@ -0,0 +1,104 @@ + + + + + + {% block title %}Weather{% endblock %} + + + + +
+ +
+
+ + +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+
+
+ + diff --git a/sites/weather/templates/explore.html b/sites/weather/templates/explore.html new file mode 100644 index 0000000..cd812fa --- /dev/null +++ b/sites/weather/templates/explore.html @@ -0,0 +1,35 @@ +{% extends 'base.html' %} +{% block title %}Explore · Weather{% endblock %} +{% block content %} +
+ {% for topic in explore_topics %} + {{ topic.label }} + {% endfor %} +
+ +
+ + {{ explore_hero.title }} + +
+

{{ explore_hero.title }}

+

{{ explore_hero.description }}

+ {{ explore_hero.cta }} +
+
+ +
+ {% for item in explore_items %} +
+ + {{ item.title }} + +

{{ item.title }}

+

{{ item.description }}

+ {% if item.duration %} + Video Player {{ item.duration }} + {% endif %} +
+ {% endfor %} +
+{% endblock %} diff --git a/sites/weather/templates/forecast_10day.html b/sites/weather/templates/forecast_10day.html new file mode 100644 index 0000000..c7505a2 --- /dev/null +++ b/sites/weather/templates/forecast_10day.html @@ -0,0 +1,100 @@ +{% extends 'base.html' %} +{% block title %}10-Day · {{ location.city }}{% endblock %} +{% block content %} +
+
+

10 Day Weather

+

{{ location.city }} 10-Day Forecast

+

10 Day Weather- {{ location.city }}, {{ location.region }}

+

Weather for the next 10 days.

+
+ +
+ +
+
+
+

Daily Forecast

+

Next 10 Days

+
+
+
+ {% for day in days %} +
+
+ {{ day.label }} + {{ day.condition_label }} +
+
+ Rain {{ day.precip_pct }}% + Sunrise {{ day.sunrise }} + Sunset {{ day.sunset }} +
+
{{ fmt_temp(day.high_f) }}{{ fmt_temp(day.low_f) }}
+
+ {% endfor %} +
+
+ +
+
+
+

Health & Activities

+

Allergy and Outdoor Activity

+
+
+
+
+

Allergy Tracker

+

Allergy Tracker

+

Daily allergy outlook.

+
+
+

Outdoor Activity

+

Outdoor Activity

+

Daily outdoor activity outlook.

+
+
+
+ +{% if media_sections.hero_card %} +
+

Top Stories

+
+ {{ media_sections.hero_card.title }} +

{{ media_sections.hero_card.title }}

+
+
+ {% for card in media_sections.tile_cards %} + + {% endfor %} +
+
+{% endif %} + +{% if media_sections.video_cards %} +
+

Video

+ {% for card in media_sections.video_cards %} +
+ + {{ card.title }} + + +
+ {% endfor %} +
+{% endif %} +{% endblock %} diff --git a/sites/weather/templates/hourly.html b/sites/weather/templates/hourly.html new file mode 100644 index 0000000..2ec5e4a --- /dev/null +++ b/sites/weather/templates/hourly.html @@ -0,0 +1,97 @@ +{% extends 'base.html' %} +{% block title %}Hourly · {{ location.city }}{% endblock %} +{% block content %} +
+
+

Hourly Weather

+

{{ location.city }} Hourly Forecast

+

Hourly Weather- {{ location.city }}, {{ location.region }}

+

Hourly weather forecast.

+
+ +
+ +
+
+
+

Hourly Forecast

+

Conditions by Hour

+
+
+
+ {% for hour in hours %} +
+ {{ hour.forecast_time.strftime('%a %-I %p') }} +
{{ fmt_temp(hour.temperature_f) }}
+

{{ hour.condition_label }}

+
+ Rain {{ hour.precip_pct }}% + Wind {{ fmt_wind(hour.wind_mph) }} +
+
+ {% endfor %} +
+
+ +
+
+
+

Health & Activities

+

Air Quality and Allergy

+
+
+
+
+

Air Quality Index

+

Air Quality Index

+

Current air quality conditions.

+
+
+

Allergy Tracker

+

Allergy Tracker

+

Allergy outlook for this location.

+
+
+
+ +{% if media_sections.hero_card %} +
+

Top Stories

+
+ {{ media_sections.hero_card.title }} +

{{ media_sections.hero_card.title }}

+
+
+ {% for card in media_sections.tile_cards %} + + {% endfor %} +
+
+{% endif %} + +{% if media_sections.video_cards %} +
+

Video

+ {% for card in media_sections.video_cards %} +
+ + {{ card.title }} + + +
+ {% endfor %} +
+{% endif %} +{% endblock %} diff --git a/sites/weather/templates/index.html b/sites/weather/templates/index.html new file mode 100644 index 0000000..b68ffa9 --- /dev/null +++ b/sites/weather/templates/index.html @@ -0,0 +1,141 @@ +{% extends 'base.html' %} +{% block title %}Weather{% endblock %} +{% block content %} +{% set lead = lead_location %} +
+
+
+ Advertisement +
+ {% if lead and lead_condition %} +
+
+

{{ lead.city }}, {{ lead.region }} {{ lead.country }}

+ As of 8:00 am +
+
+
+
{{ fmt_temp(lead_condition.temperature_f) }}
+
{{ lead_condition.condition_label }}
+
Day {{ fmt_temp(forecast_hint.high_f if forecast_hint else lead_condition.temperature_f + 4) }} • Night {{ fmt_temp(forecast_hint.low_f if forecast_hint else lead_condition.temperature_f - 8) }}
+
+ See in-depth forecast +
+
+ {% endif %} + + {% if spotlight_modules %} +
+
+
+

Health & Activities

+

Air Quality, Allergy, and Outdoor Conditions

+
+
+
+
+

Air Quality Index

+

{{ spotlight_modules.air_quality_module.label }}

+
{{ spotlight_modules.air_quality_module.value }}
+

{{ spotlight_modules.air_quality_module.description }}

+
+
+

Allergy Tracker

+

{{ spotlight_modules.allergy_module.label }}

+
{{ spotlight_modules.allergy_module.value }}
+

{{ spotlight_modules.allergy_module.description }}

+
+
+

Cold & Flu Tracker

+

{{ spotlight_modules.flu_module.label }}

+
{{ fmt_temp(spotlight_modules.flu_module.value) }}
+

{{ spotlight_modules.flu_module.description }}

+
+
+

Outdoor Activity

+

{{ spotlight_modules.outdoor_module.label }}

+
{{ fmt_temp(spotlight_modules.outdoor_module.value) }}
+

{{ spotlight_modules.outdoor_module.description }}

+
+
+
+ {% endif %} + +
+

Top Stories

+
+ {{ hero_story.title }} +

{{ hero_story.title }}

+ {% if hero_story.summary %} +

{{ hero_story.summary }}

+ {% endif %} +
+
+ {% for item in top_story_tiles %} +
+ {{ item.title }} +

{{ item.title }}

+
+ {% endfor %} +
+
+ {% for headline in top_story_headlines %} + {{ headline }} + {% endfor %} +
+
+ {% if video_cards %} +
+

Video

+ {% for card in video_cards %} +
+ + {{ card.title }} + + +
+ {% endfor %} +
+ {% endif %} +
+ + +
+{% endblock %} diff --git a/sites/weather/templates/location.html b/sites/weather/templates/location.html new file mode 100644 index 0000000..150efd3 --- /dev/null +++ b/sites/weather/templates/location.html @@ -0,0 +1,169 @@ +{% extends 'base.html' %} +{% block title %}{{ location.city }} Weather{% endblock %} +{% block content %} +
+
+

Today’s Weather

+

{{ location.city }}, {{ location.region }}

+

{{ location.country }} · As of 8:00 am

+
+ +
+ +
+
+
+
+ {{ location.city }} skyline +
+
+
+
{{ fmt_temp(conditions.temperature_f) }}
+
{{ conditions.condition_label }}
+

Feels Like {{ fmt_temp(conditions.feels_like_f) }} · Air Quality {{ conditions.air_quality }}

+
+
+
Humidity{{ conditions.humidity }}%
+
Wind{{ fmt_wind(conditions.wind_mph, conditions.wind_direction) }}
+
UV Index{{ conditions.uv_index }}
+
Visibility{{ fmt_distance(conditions.visibility_mi) }}
+
+
+
+ +
+
+
+

Hourly Weather

+

Hourly Forecast

+
+ View all +
+
+ {% for hour in hourly %} +
+ {{ hour.forecast_time.strftime('%-I %p') }} +
{{ fmt_temp(hour.temperature_f) }}
+

{{ hour.condition_label }}

+ {{ hour.precip_pct }}% Rain +
+ {% endfor %} +
+
+ +
+
+
+

10 Day Weather

+

Daily Forecast

+
+ Open 10 Day +
+
+ {% for day in forecast %} +
+
+ {{ day.label }} + {{ day.condition_label }} +
+
{{ day.precip_pct }}%
+
{{ fmt_temp(day.high_f) }}{{ fmt_temp(day.low_f) }}
+
+ {% endfor %} +
+
+ +
+
+
+

Health & Activities

+

Air Quality, Allergy, and Outdoor Conditions

+
+
+
+
+

Air Quality Index

+

{{ health_modules.air_quality_module.label }}

+
{{ health_modules.air_quality_module.value }}
+

{{ health_modules.air_quality_module.description }}

+
+
+

Allergy Tracker

+

{{ health_modules.allergy_module.label }}

+
{{ health_modules.allergy_module.value }}
+

{{ health_modules.allergy_module.description }}

+
+
+

Cold & Flu

+

{{ health_modules.flu_module.label }}

+
{{ fmt_temp(health_modules.flu_module.value) }}
+

{{ health_modules.flu_module.description }}

+
+
+

Outdoor Activity

+

{{ health_modules.outdoor_module.label }}

+
{{ fmt_temp(health_modules.outdoor_module.value) }}
+

{{ health_modules.outdoor_module.description }}

+
+
+
+
+ + +
+{% endblock %} diff --git a/sites/weather/templates/login.html b/sites/weather/templates/login.html new file mode 100644 index 0000000..12ed739 --- /dev/null +++ b/sites/weather/templates/login.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} +{% block title %}Sign in · Weather{% endblock %} +{% block content %} +
+

Sign in

+
+ {{ form.hidden_tag() }} + + + +
+
+{% endblock %} diff --git a/sites/weather/templates/radar.html b/sites/weather/templates/radar.html new file mode 100644 index 0000000..858c0dc --- /dev/null +++ b/sites/weather/templates/radar.html @@ -0,0 +1,85 @@ +{% extends 'base.html' %} +{% block title %}Radar · {{ location.city }}{% endblock %} +{% block content %} +
+
+

Radar

+

{{ location.city }} Radar Map

+

{{ location.city | upper }}, {{ location.region | upper }} RADAR MAP

+

Radar map and precipitation view.

+
+ +
+ +
+
+ {{ location.city }} radar image +
+
+ Current Radar +

Current radar imagery for {{ location.city }}, {{ location.region }}.

+
+
+ +
+
+
+

Health & Activities

+

Air Quality and Allergy

+
+
+
+
+

Air Quality Index

+

Air Quality Index

+

Current air quality conditions.

+
+
+

Allergy Tracker

+

Allergy Tracker

+

Allergy outlook for this location.

+
+
+
+ +{% if media_sections.hero_card %} +
+

Top Stories

+
+ {{ media_sections.hero_card.title }} +

{{ media_sections.hero_card.title }}

+
+
+ {% for card in media_sections.tile_cards %} + + {% endfor %} +
+
+{% endif %} + +{% if media_sections.video_cards %} +
+

Video

+ {% for card in media_sections.video_cards %} +
+ + {{ card.title }} + + +
+ {% endfor %} +
+{% endif %} +{% endblock %} diff --git a/sites/weather/templates/register.html b/sites/weather/templates/register.html new file mode 100644 index 0000000..03f18e3 --- /dev/null +++ b/sites/weather/templates/register.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} +{% block title %}Register · Weather{% endblock %} +{% block content %} +
+

Create account

+
+ {{ form.hidden_tag() }} + + + + + +
+
+{% endblock %} diff --git a/sites/weather/templates/search.html b/sites/weather/templates/search.html new file mode 100644 index 0000000..57592b7 --- /dev/null +++ b/sites/weather/templates/search.html @@ -0,0 +1,35 @@ +{% extends 'base.html' %} +{% block title %}Search · Weather{% endblock %} +{% block content %} +
+
+

Search

+

Search results

+

{{ locations|length }} locations for “{{ query }}”.

+
+
+
+ {% for location in locations %} + {% set condition = result_conditions.get(location.id) %} +
+ {{ location.city }} skyline +
+

{{ location.city }}, {{ location.region }}

+

{{ location.country }}

+ {% if condition %} +

{{ condition.temperature_f }}° · {{ condition.condition_label }}

+ {% endif %} +

{{ location.summary }}

+
+
+ Today + 10 Day +
+
+ {% else %} +
+

No locations matched this search.

+
+ {% endfor %} +
+{% endblock %} diff --git a/sites/weather/templates/video.html b/sites/weather/templates/video.html new file mode 100644 index 0000000..27d51e3 --- /dev/null +++ b/sites/weather/templates/video.html @@ -0,0 +1,37 @@ +{% extends 'base.html' %} +{% block title %}Video · Weather{% endblock %} +{% block content %} +
+
+
+ {{ featured_video.title }} +
+ Now Playing + {{ featured_video.duration }} +
+
+

{{ featured_video.title }}

+

{{ featured_video.time_label }}

+

{{ featured_video.updated }}

+

{{ featured_video.published }}

+

{{ featured_video.description }}

+

Open upstream reference

+
+ + +
+{% endblock %} diff --git a/sites/youtube/_health.py b/sites/youtube/_health.py new file mode 100644 index 0000000..6f21f87 --- /dev/null +++ b/sites/youtube/_health.py @@ -0,0 +1,4 @@ +"""Per-site health probe.""" + +def health(): + return {"ok": True, "site": "youtube"} diff --git a/sites/youtube/app.py b/sites/youtube/app.py new file mode 100644 index 0000000..75733b5 --- /dev/null +++ b/sites/youtube/app.py @@ -0,0 +1,555 @@ +import importlib.util +import json +import os +import re +from datetime import datetime + +from flask import Flask, flash, redirect, render_template, request, url_for +from flask_bcrypt import Bcrypt +from flask_login import (LoginManager, UserMixin, current_user, login_required, + login_user, logout_user) +from flask_sqlalchemy import SQLAlchemy +from flask_wtf import FlaskForm +from flask_wtf.csrf import CSRFProtect, generate_csrf +from sqlalchemy import or_ +from wtforms import PasswordField, SelectField, StringField, TextAreaField +from wtforms.validators import DataRequired, Email, EqualTo, Length, Optional + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +MIRROR_REFERENCE_DATE = datetime(2024, 3, 1, 12, 0, 0) +MIRROR_REFERENCE_DATE_LABEL = MIRROR_REFERENCE_DATE.strftime('%B %-d, %Y') +BENCHMARK_USER_AVATAR_PATHS = { + 'alice.j@test.com': '/static/images/youtube/upstream/channels/c_067_avatar.jpg', + 'bob.c@test.com': '/static/images/youtube/upstream/channels/c_035_avatar.jpg', + 'carol.d@test.com': '/static/images/youtube/upstream/channels/c_010_avatar.jpg', + 'david.k@test.com': '/static/images/youtube/upstream/channels/c_007_avatar.jpg', +} + + +def mirror_now() -> datetime: + return MIRROR_REFERENCE_DATE + + +def user_avatar_path(user: UserMixin | None) -> str: + if not user or not getattr(user, 'email', None): + return '' + return BENCHMARK_USER_AVATAR_PATHS.get(user.email.lower(), '') + + +app = Flask(__name__, template_folder=os.path.join(BASE_DIR, 'templates'), static_folder=os.path.join(BASE_DIR, 'static')) +app.config['SECRET_KEY'] = 'webharbor-youtube-dev-key' +app.config['SQLALCHEMY_DATABASE_URI'] = f"sqlite:///{os.path.join(BASE_DIR, 'instance', 'youtube.db')}" +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['WTF_CSRF_TIME_LIMIT'] = None +os.makedirs(os.path.join(BASE_DIR, 'instance'), exist_ok=True) + +db = SQLAlchemy(app) +bcrypt = Bcrypt(app) +login_manager = LoginManager(app) +login_manager.login_view = 'login' +login_manager.login_message = 'Please sign in to continue.' +csrf = CSRFProtect(app) + + +class User(db.Model, UserMixin): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(120), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + display_name = db.Column(db.String(120), nullable=False) + handle = db.Column(db.String(80), unique=True, nullable=False, index=True) + avatar_color = db.Column(db.String(20), default='#3ea6ff') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + comments = db.relationship('Comment', backref='user', lazy=True, cascade='all, delete-orphan') + subscriptions = db.relationship('Subscription', backref='user', lazy=True, cascade='all, delete-orphan') + watch_later_items = db.relationship('WatchLater', backref='user', lazy=True, cascade='all, delete-orphan') + history_items = db.relationship('WatchHistory', backref='user', lazy=True, cascade='all, delete-orphan') + liked_videos = db.relationship('UserLike', backref='user', lazy=True, cascade='all, delete-orphan') + + def set_password(self, raw: str): + self.password_hash = bcrypt.generate_password_hash(raw).decode('utf-8') + + def check_password(self, raw: str) -> bool: + return bcrypt.check_password_hash(self.password_hash, raw) + + @property + def initials(self): + parts = self.display_name.split() + if len(parts) >= 2: + return (parts[0][0] + parts[-1][0]).upper() + return self.display_name[:2].upper() + + +class Channel(db.Model): + __tablename__ = 'channels' + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(80), unique=True, nullable=False, index=True) + name = db.Column(db.String(120), nullable=False) + category = db.Column(db.String(60), default='General') + description = db.Column(db.Text, default='') + subscriber_count = db.Column(db.Integer, default=0) + avatar_path = db.Column(db.String(300), default='') + banner_path = db.Column(db.String(300), default='') + verified = db.Column(db.Boolean, default=False) + accent_color = db.Column(db.String(20), default='#3ea6ff') + + videos = db.relationship('Video', backref='channel', lazy=True, cascade='all, delete-orphan') + playlists = db.relationship('Playlist', backref='channel', lazy=True, cascade='all, delete-orphan') + + +class Video(db.Model): + __tablename__ = 'videos' + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(120), unique=True, nullable=False, index=True) + title = db.Column(db.String(255), nullable=False) + channel_id = db.Column(db.Integer, db.ForeignKey('channels.id'), nullable=False) + description = db.Column(db.Text, default='') + category = db.Column(db.String(60), default='General') + tags_json = db.Column(db.Text, default='[]') + duration = db.Column(db.String(20), default='10:00') + duration_seconds = db.Column(db.Integer, default=600) + thumbnail_path = db.Column(db.String(300), default='') + poster_path = db.Column(db.String(300), default='') + views = db.Column(db.Integer, default=0) + likes = db.Column(db.Integer, default=0) + comment_count = db.Column(db.Integer, default=0) + published_at = db.Column(db.DateTime, default=mirror_now) + is_trending = db.Column(db.Boolean, default=False) + comments_enabled = db.Column(db.Boolean, default=True) + + comments = db.relationship('Comment', backref='video', lazy=True, cascade='all, delete-orphan') + + def get_tags(self): + try: + return json.loads(self.tags_json or '[]') + except Exception: + return [] + + @property + def relative_date(self): + delta = mirror_now() - self.published_at + if delta.days <= 0: + hours = max(1, delta.seconds // 3600) + return f'{hours} hours ago' + if delta.days < 7: + return f'{delta.days} days ago' + if delta.days < 30: + weeks = max(1, delta.days // 7) + return f'{weeks} weeks ago' + months = max(1, delta.days // 30) + return f'{months} months ago' + + @property + def view_label(self): + if self.views >= 1_000_000: + return f'{self.views / 1_000_000:.1f}M views' + if self.views >= 1_000: + return f'{self.views / 1_000:.0f}K views' + return f'{self.views} views' + + +class Playlist(db.Model): + __tablename__ = 'playlists' + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(120), unique=True, nullable=False, index=True) + channel_id = db.Column(db.Integer, db.ForeignKey('channels.id'), nullable=False) + title = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text, default='') + is_public = db.Column(db.Boolean, default=True) + + items = db.relationship('PlaylistVideo', backref='playlist', lazy=True, cascade='all, delete-orphan') + + +class PlaylistVideo(db.Model): + __tablename__ = 'playlist_videos' + id = db.Column(db.Integer, primary_key=True) + playlist_id = db.Column(db.Integer, db.ForeignKey('playlists.id'), nullable=False) + video_id = db.Column(db.Integer, db.ForeignKey('videos.id'), nullable=False) + position = db.Column(db.Integer, default=1) + video = db.relationship('Video') + + +class Comment(db.Model): + __tablename__ = 'comments' + id = db.Column(db.Integer, primary_key=True) + video_id = db.Column(db.Integer, db.ForeignKey('videos.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + body = db.Column(db.Text, nullable=False) + like_count = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class Subscription(db.Model): + __tablename__ = 'subscriptions' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + channel_id = db.Column(db.Integer, db.ForeignKey('channels.id'), nullable=False) + subscribed_at = db.Column(db.DateTime, default=datetime.utcnow) + channel = db.relationship('Channel') + + +class WatchLater(db.Model): + __tablename__ = 'watch_later' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + video_id = db.Column(db.Integer, db.ForeignKey('videos.id'), nullable=False) + added_at = db.Column(db.DateTime, default=datetime.utcnow) + video = db.relationship('Video') + + +class WatchHistory(db.Model): + __tablename__ = 'watch_history' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + video_id = db.Column(db.Integer, db.ForeignKey('videos.id'), nullable=False) + watched_at = db.Column(db.DateTime, default=datetime.utcnow) + video = db.relationship('Video') + + +class UserLike(db.Model): + __tablename__ = 'user_likes' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + video_id = db.Column(db.Integer, db.ForeignKey('videos.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + video = db.relationship('Video') + + +class LoginForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired()]) + + +class RegisterForm(FlaskForm): + display_name = StringField('Display name', validators=[DataRequired(), Length(min=2, max=120)]) + handle = StringField('Handle', validators=[DataRequired(), Length(min=2, max=80)]) + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired(), Length(min=8)]) + confirm_password = PasswordField('Confirm password', validators=[DataRequired(), EqualTo('password')]) + + +class CommentForm(FlaskForm): + body = TextAreaField('Comment', validators=[DataRequired(), Length(min=2, max=800)]) + + +class SearchForm(FlaskForm): + search_query = StringField('Search', validators=[Optional()]) + category = SelectField('Category', choices=[], validators=[Optional()]) + + +@login_manager.user_loader +def load_user(user_id: str): + return db.session.get(User, int(user_id)) + + +def slugify(value: str) -> str: + value = re.sub(r'[^a-zA-Z0-9]+', '-', value.lower()).strip('-') + return value or 'item' + + +def score_video(video: Video, query: str) -> int: + terms = [term for term in re.findall(r'[a-z0-9]+', query.lower()) if term] + haystacks = [ + video.title.lower(), + video.description.lower(), + video.channel.name.lower(), + ' '.join(video.get_tags()).lower(), + video.category.lower(), + ] + score = 0 + for term in terms: + if term in haystacks[0]: + score += 8 + if any(term in haystack for haystack in haystacks[1:]): + score += 4 + if video.is_trending: + score += 2 + return score + + +def nav_categories(): + return ['All', 'Music', 'Gaming', 'Science', 'Technology', 'Cooking', 'Travel', 'Live', 'Podcasts'] + + +@app.context_processor +def inject_globals(): + return { + 'mirror_reference_date_label': MIRROR_REFERENCE_DATE_LABEL, + 'mirror_now': mirror_now, + 'nav_categories': nav_categories(), + 'generate_csrf': generate_csrf, + 'user_avatar_path': user_avatar_path, + } + + +@app.route('/') +def index(): + selected = request.args.get('category', 'All') + query = Video.query.order_by(Video.is_trending.desc(), Video.views.desc()) + if selected and selected != 'All': + query = query.filter_by(category=selected) + videos = query.limit(18).all() + trending = Video.query.filter_by(is_trending=True).order_by(Video.views.desc()).limit(6).all() + return render_template('index.html', videos=videos, trending=trending, selected_category=selected) + + +@app.route('/results') +def results(): + search_query = (request.args.get('search_query') or '').strip() + category = (request.args.get('category') or 'All').strip() or 'All' + active_filter = (request.args.get('filter') or 'All').strip() or 'All' + filter_options = ['All', 'Watched', 'Unwatched', 'Recently uploaded', 'Live'] + if active_filter not in filter_options: + active_filter = 'All' + videos = Video.query.options(db.joinedload(Video.channel)).all() + if category != 'All': + videos = [video for video in videos if video.category == category] + if search_query: + ranked = [(score_video(video, search_query), video) for video in videos] + ranked = [pair for pair in ranked if pair[0] > 0] + ranked.sort(key=lambda item: (item[0], item[1].views), reverse=True) + videos = [video for _, video in ranked] + else: + videos.sort(key=lambda video: (video.is_trending, video.views), reverse=True) + watched_ids = set() + if current_user.is_authenticated: + watched_ids = { + item.video_id + for item in WatchHistory.query.filter_by(user_id=current_user.id).all() + } + if active_filter == 'Watched': + videos = [video for video in videos if video.id in watched_ids] + elif active_filter == 'Unwatched': + videos = [video for video in videos if video.id not in watched_ids] + elif active_filter == 'Recently uploaded': + videos = sorted(videos, key=lambda video: video.published_at, reverse=True) + elif active_filter == 'Live': + videos = [video for video in videos if 'live' in video.category.lower() or 'live' in video.title.lower()] + return render_template( + 'results.html', + videos=videos, + search_query=search_query, + selected_category=category, + active_filter=active_filter, + filter_options=filter_options, + ) + + +@app.route('/watch/', methods=['GET', 'POST']) +def watch(slug: str): + video = Video.query.filter_by(slug=slug).first_or_404() + form = CommentForm() + if form.validate_on_submit() and current_user.is_authenticated and video.comments_enabled: + comment = Comment(video_id=video.id, user_id=current_user.id, body=form.body.data.strip(), like_count=1) + db.session.add(comment) + video.comment_count += 1 + db.session.commit() + flash('Comment added.', 'success') + return redirect(url_for('watch', slug=slug)) + related = (Video.query.filter(Video.category == video.category, Video.id != video.id) + .order_by(Video.views.desc()).limit(8).all()) + comments = Comment.query.filter_by(video_id=video.id).order_by(Comment.like_count.desc()).limit(20).all() + in_watch_later = False + liked = False + subscribed = False + if current_user.is_authenticated: + in_watch_later = WatchLater.query.filter_by(user_id=current_user.id, video_id=video.id).first() is not None + liked = UserLike.query.filter_by(user_id=current_user.id, video_id=video.id).first() is not None + subscribed = Subscription.query.filter_by(user_id=current_user.id, channel_id=video.channel_id).first() is not None + if WatchHistory.query.filter_by(user_id=current_user.id, video_id=video.id).first() is None: + db.session.add(WatchHistory(user_id=current_user.id, video_id=video.id, watched_at=mirror_now())) + db.session.commit() + return render_template('watch.html', video=video, related=related, comments=comments, form=form, + in_watch_later=in_watch_later, liked=liked, subscribed=subscribed) + + +@app.route('/channel/') +def channel(slug: str): + channel_obj = Channel.query.filter_by(slug=slug).first_or_404() + active_tab = (request.args.get('tab') or 'home').strip().lower() + if active_tab not in {'home', 'videos', 'playlists', 'about'}: + active_tab = 'home' + videos = Video.query.filter_by(channel_id=channel_obj.id).order_by(Video.views.desc()).all() + playlists = Playlist.query.filter_by(channel_id=channel_obj.id).all() + return render_template('channel.html', channel=channel_obj, videos=videos, playlists=playlists, active_tab=active_tab) + + +@app.route('/playlist/') +def playlist(slug: str): + playlist_obj = Playlist.query.filter_by(slug=slug).first_or_404() + items = PlaylistVideo.query.filter_by(playlist_id=playlist_obj.id).order_by(PlaylistVideo.position.asc()).all() + return render_template('playlist.html', playlist=playlist_obj, items=items) + + +@app.route('/feed/trending') +def trending(): + videos = Video.query.filter_by(is_trending=True).order_by(Video.views.desc()).all() + return render_template('feed_listing.html', title='Trending', subtitle='Popular videos right now.', videos=videos) + + +@app.route('/feed/subscriptions') +@login_required +def subscriptions_feed(): + channel_ids = [sub.channel_id for sub in Subscription.query.filter_by(user_id=current_user.id).all()] + videos = [] + if channel_ids: + videos = Video.query.filter(Video.channel_id.in_(channel_ids)).order_by(Video.published_at.desc()).all() + return render_template('feed_listing.html', title='Subscriptions', subtitle='Fresh uploads from channels you follow.', videos=videos) + + +@app.route('/feed/history') +@login_required +def history_feed(): + items = WatchHistory.query.filter_by(user_id=current_user.id).order_by(WatchHistory.watched_at.desc()).all() + videos = [item.video for item in items] + return render_template('feed_listing.html', title='History', subtitle='Videos you have watched recently.', videos=videos) + + +@app.route('/feed/watch-later') +@login_required +def watch_later_feed(): + items = WatchLater.query.filter_by(user_id=current_user.id).order_by(WatchLater.added_at.desc()).all() + videos = [item.video for item in items] + return render_template('feed_listing.html', title='Watch Later', subtitle='Saved videos to watch later.', videos=videos) + + +@app.route('/feed/liked') +@login_required +def liked_feed(): + items = UserLike.query.filter_by(user_id=current_user.id).order_by(UserLike.created_at.desc()).all() + videos = [item.video for item in items] + return render_template('feed_listing.html', title='Liked Videos', subtitle='Videos you have liked on this account.', videos=videos) + + +@app.route('/account', methods=['GET', 'POST']) +@login_required +def account(): + if request.method == 'POST': + current_user.display_name = (request.form.get('display_name') or current_user.display_name).strip() + current_user.handle = slugify((request.form.get('handle') or current_user.handle).strip()) + db.session.commit() + flash('Account updated.', 'success') + return redirect(url_for('account')) + subscriptions = (Subscription.query.filter_by(user_id=current_user.id) + .join(Channel, Subscription.channel_id == Channel.id) + .order_by(Channel.name.asc()) + .all()) + subscribed_channels = [subscription.channel for subscription in subscriptions] + stats = { + 'subscriptions': len(subscribed_channels), + 'watch_later': WatchLater.query.filter_by(user_id=current_user.id).count(), + 'likes': UserLike.query.filter_by(user_id=current_user.id).count(), + 'history': WatchHistory.query.filter_by(user_id=current_user.id).count(), + 'comments': Comment.query.filter_by(user_id=current_user.id).count(), + } + return render_template('account.html', subscribed_channels=subscribed_channels, stats=stats) + + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('index')) + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data.lower().strip()).first() + if user and user.check_password(form.password.data): + login_user(user) + flash('Welcome back.', 'success') + return redirect(request.args.get('next') or url_for('index')) + flash('Invalid email or password.', 'error') + return render_template('login.html', form=form) + + +@app.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('index')) + form = RegisterForm() + if form.validate_on_submit(): + handle = slugify(form.handle.data) + if User.query.filter(or_(User.email == form.email.data.lower().strip(), User.handle == handle)).first(): + flash('An account with that email or handle already exists.', 'error') + else: + user = User(email=form.email.data.lower().strip(), display_name=form.display_name.data.strip(), handle=handle) + user.set_password(form.password.data) + db.session.add(user) + db.session.commit() + login_user(user) + flash('Your channel account is ready.', 'success') + return redirect(url_for('index')) + return render_template('register.html', form=form) + + +@app.route('/logout', methods=['POST']) +@login_required +def logout(): + logout_user() + flash('Signed out.', 'success') + return redirect(url_for('index')) + + +@app.route('/api/toggle-watch-later/', methods=['POST']) +@login_required +def toggle_watch_later(slug: str): + video = Video.query.filter_by(slug=slug).first_or_404() + existing = WatchLater.query.filter_by(user_id=current_user.id, video_id=video.id).first() + if existing: + db.session.delete(existing) + state = 'removed' + else: + db.session.add(WatchLater(user_id=current_user.id, video_id=video.id, added_at=mirror_now())) + state = 'saved' + db.session.commit() + flash(f'Video {state} from Watch Later.', 'success') + return redirect(request.referrer or url_for('watch', slug=slug)) + + +@app.route('/api/toggle-like/', methods=['POST']) +@login_required +def toggle_like(slug: str): + video = Video.query.filter_by(slug=slug).first_or_404() + existing = UserLike.query.filter_by(user_id=current_user.id, video_id=video.id).first() + if existing: + db.session.delete(existing) + video.likes = max(0, video.likes - 1) + else: + db.session.add(UserLike(user_id=current_user.id, video_id=video.id, created_at=mirror_now())) + video.likes += 1 + db.session.commit() + return redirect(request.referrer or url_for('watch', slug=slug)) + + +@app.route('/api/toggle-subscription/', methods=['POST']) +@login_required +def toggle_subscription(slug: str): + channel_obj = Channel.query.filter_by(slug=slug).first_or_404() + existing = Subscription.query.filter_by(user_id=current_user.id, channel_id=channel_obj.id).first() + if existing: + db.session.delete(existing) + else: + db.session.add(Subscription(user_id=current_user.id, channel_id=channel_obj.id, subscribed_at=mirror_now())) + db.session.commit() + return redirect(request.referrer or url_for('channel', slug=slug)) + + +def load_seed_module(): + seed_path = os.path.join(BASE_DIR, 'seed_data.py') + spec = importlib.util.spec_from_file_location('youtube_seed_data', seed_path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +seed_module = load_seed_module() + +with app.app_context(): + db.create_all() + seed_module.seed_database(db, Channel, Video, Playlist, PlaylistVideo) + seed_module.seed_benchmark_users(db, User, Subscription, WatchLater, WatchHistory, UserLike, Comment, Video, Channel) + +@app.route('/_health') +def health(): + return {'ok': True, 'site': 'youtube', 'videos': Video.query.count(), 'channels': Channel.query.count()} + diff --git a/sites/youtube/requirements.txt b/sites/youtube/requirements.txt new file mode 100644 index 0000000..c84156c --- /dev/null +++ b/sites/youtube/requirements.txt @@ -0,0 +1,7 @@ +Flask +Flask-SQLAlchemy +Flask-Login +Flask-WTF +Flask-Bcrypt +WTForms +email-validator diff --git a/sites/youtube/seed_data.py b/sites/youtube/seed_data.py new file mode 100644 index 0000000..8472a3c --- /dev/null +++ b/sites/youtube/seed_data.py @@ -0,0 +1,296 @@ +import json +import os +from glob import glob +from datetime import datetime, timedelta + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +CHANNEL_ASSET_SLUGS = { + 'quantum-lab': {'avatar': 'quantum-lab-avatar', 'banner': 'quantum-lab-banner'}, + 'frame-by-frame': {'avatar': 'frame-by-frame-avatar', 'banner': 'frame-by-frame-banner'}, + 'night-shift-jazz': {'avatar': 'night-shift-jazz-avatar', 'banner': 'night-shift-jazz-banner'}, + 'pixel-quest': {'avatar': 'pixel-quest-avatar', 'banner': 'pixel-quest-banner'}, + 'pantry-notes': {'avatar': 'pantry-notes-avatar', 'banner': 'pantry-notes-banner'}, + 'window-seat': {'avatar': 'window-seat-avatar', 'banner': 'window-seat-banner'}, +} +CHANNEL_UPSTREAM_AVATARS = { + 'quantum-lab': '/static/images/youtube/upstream/channels/c_010_avatar.jpg', + 'frame-by-frame': '/static/images/youtube/upstream/channels/c_011_avatar.jpg', + 'night-shift-jazz': '/static/images/youtube/upstream/channels/c_024_avatar.jpg', + 'pixel-quest': '/static/images/youtube/upstream/channels/c_012_avatar.jpg', + 'pantry-notes': '/static/images/youtube/upstream/channels/c_013_avatar.jpg', + 'window-seat': '/static/images/youtube/upstream/channels/c_023_avatar.jpg', +} +LOCAL_VIDEO_TO_UPSTREAM_ID = { + 'how-quantum-sensors-read-invisible-changes': 'yFRoKxOkNSk', + 'why-lunar-dust-destroys-precision-hardware': 's9ALylTC9YQ', + 'building-a-tabletop-gravity-wave-demo': 'VrXIjava968', + 'inside-the-tiny-pc-that-replaced-my-laptop': 'UjRWQND6_ro', + 'can-this-studio-camera-beat-a-flagship-phone': 'n5QeBru9Rzk', + 'three-display-calibrators-tested-back-to-back': 'Otim2mDjsYM', + 'loft-session-midnight-rhodes-and-tape-echo': 'uVofSpZxhEs', + 'rainy-city-vinyl-mix-for-late-work': 'h4Gnqv0AvQ8', + 'sunrise-sax-theme-with-analog-delay': '2HPQxTUw5ds', + 'speedrunning-the-archive-ruins-in-18-minutes': 'Vo6QTBMdUfU', + 'which-stealth-build-survives-nightmare-mode': 'C952MlU-5fE', + 'five-open-world-settings-that-still-feel-new': 'Bbp5g1MhCLY', + 'the-crispy-chili-oil-noodles-i-make-weekly': 'OAZpSsu03VA', + 'freezer-dumplings-with-a-restaurant-finish': 'MPqR0Q4i1D0', + 'three-knife-skills-that-change-weeknight-cooking': 'b67vr72fNtc', + 'a-weekend-rail-journey-across-northern-spain': 'wIW_VbXa58E', + 'how-to-pack-one-bag-for-a-rainy-spring-city': '5DcBkOs6hQA', + 'the-quiet-coffee-streets-of-kyoto-at-dawn': 'YnOH3nGfF-0', + 'can-you-hear-a-starquake-through-data': 'KW4yBSV4U38', + 'desk-studio-lighting-under-100-dollars': 'I2F2xFvt4mQ', + 'blue-hour-piano-loop-for-deep-focus': 'xESVaYvG4xE', + 'best-controller-settings-for-faster-aiming': 'kae1JzT93ao', + 'one-pan-garlic-rice-for-busy-weeknights': 'YYsg_vZEDng', + '48-hours-in-lisbon-without-a-car': 'U_dt_b-kMME', +} + + +def image_path(section: str, slug: str, ext: str = 'svg') -> str: + return f'/static/images/{section}/{slug}.{ext}' + + +def pick_existing_image(section: str, slug: str, preferred_exts: tuple[str, ...]) -> str: + for ext in preferred_exts: + rel = image_path(section, slug, ext) + abs_path = os.path.join(BASE_DIR, rel.lstrip('/')) + if os.path.exists(abs_path): + return rel + return '' + + +def pick_upstream_video_image(video_id: str, kind: str) -> str: + if not video_id: + return '' + folder = 'thumbnails' if kind == 'thumbnail' else 'posters' + pattern = os.path.join(BASE_DIR, 'static', 'images', 'youtube', 'upstream', folder, f'v_*_{video_id}.jpg') + matches = glob(pattern) + if not matches: + return '' + filename = os.path.basename(matches[0]) + return f'/static/images/youtube/upstream/{folder}/{filename}' + + +def pick_video_asset(video_slug: str): + upstream_id = LOCAL_VIDEO_TO_UPSTREAM_ID.get(video_slug, '') + thumbnail = pick_upstream_video_image(upstream_id, 'thumbnail') or pick_existing_image('youtube/thumbnails', video_slug, ('jpg', 'svg')) + poster = pick_upstream_video_image(upstream_id, 'poster') or pick_existing_image('youtube/posters', video_slug, ('jpg', 'svg')) or thumbnail + return { + 'thumbnail_path': thumbnail, + 'poster_path': poster, + } + + +def pick_channel_asset(channel_slug: str): + slugs = CHANNEL_ASSET_SLUGS.get( + channel_slug, + {'avatar': 'frame-by-frame-avatar', 'banner': 'frame-by-frame-banner'}, + ) + avatar = CHANNEL_UPSTREAM_AVATARS.get(channel_slug, '') + if not avatar: + avatar = pick_existing_image('youtube/channels', slugs['avatar'], ('png', 'svg')) + banner = pick_existing_image('youtube/channels', slugs['banner'], ('jpg', 'svg')) + if not avatar: + avatar = image_path('youtube/channels', 'frame-by-frame-avatar', 'svg') + if not banner: + banner = image_path('youtube/channels', 'frame-by-frame-banner', 'svg') + return {'avatar_path': avatar, 'banner_path': banner} + + +def seed_database(db, Channel, Video, Playlist, PlaylistVideo): + if Channel.query.count() > 0: + return + + channels_data = [ + ('quantum-lab', 'Hafu Go', 'Science', '#7c4dff', True), + ('frame-by-frame', 'AI Uncovered', 'Technology', '#3ea6ff', True), + ('night-shift-jazz', 'Magic Club', 'Music', '#ff4e45', False), + ('pixel-quest', 'MrBeast Gaming', 'Gaming', '#00c853', True), + ('pantry-notes', 'cookingWITHfred', 'Cooking', '#ff9800', False), + ('window-seat', 'Walking OZ', 'Travel', '#00b8d4', True), + ] + channels = {} + for slug, name, category, accent, verified in channels_data: + channel_assets = pick_channel_asset(slug) + channel = Channel( + slug=slug, + name=name, + category=category, + description=f'{name} publishes polished {category.lower()} videos with strong visual storytelling and deep detail.', + subscriber_count={ + 'Science': 4820000, + 'Technology': 3610000, + 'Music': 1980000, + 'Gaming': 5280000, + 'Cooking': 1120000, + 'Travel': 2410000, + }[category], + avatar_path=channel_assets['avatar_path'], + banner_path=channel_assets['banner_path'], + verified=verified, + accent_color=accent, + ) + db.session.add(channel) + channels[slug] = channel + db.session.flush() + + published_base = datetime(2024, 3, 1, 12, 0, 0) + video_specs = [ + ('quantum-lab', 'how-quantum-sensors-read-invisible-changes', 'Level 1 to 100 Science Gadgets', 'Science', ['quantum', 'sensor', 'lab'], '14:42', 882000, 42100, True, True, 2, 'jpg'), + ('quantum-lab', 'why-lunar-dust-destroys-precision-hardware', 'Level 1 to 100 Science Experiments', 'Science', ['moon', 'engineering', 'dust'], '11:28', 531000, 19800, False, True, 6, 'jpg'), + ('quantum-lab', 'building-a-tabletop-gravity-wave-demo', 'I Tested Every Science Gadget on Amazon', 'Science', ['physics', 'demo', 'gravity'], '19:03', 263000, 11100, False, True, 11, 'jpg'), + ('frame-by-frame', 'inside-the-tiny-pc-that-replaced-my-laptop', 'I Tested the Rarest Tech in 2026!', 'Technology', ['mini pc', 'review', 'productivity'], '12:35', 1260000, 63300, True, True, 1, 'jpg'), + ('frame-by-frame', 'can-this-studio-camera-beat-a-flagship-phone', 'R.I.P. Normal Flagship Phones but Why?', 'Technology', ['camera', 'studio', 'comparison'], '18:21', 917000, 54100, True, True, 4, 'jpg'), + ('frame-by-frame', 'three-display-calibrators-tested-back-to-back', 'Top 17 New Technology Trends That Will Define 2026', 'Technology', ['display', 'color', 'creator'], '16:09', 302000, 17300, False, True, 10, 'jpg'), + ('night-shift-jazz', 'loft-session-midnight-rhodes-and-tape-echo', 'Best Acoustic Covers of Popular Songs 2026 Deep Focus & Chill Study Music', 'Music', ['jazz', 'session', 'lofi'], '36:10', 471000, 23900, True, True, 3, 'jpg'), + ('night-shift-jazz', 'rainy-city-vinyl-mix-for-late-work', 'Ibiza Summer Mix 2026 Best Of Tropical Deep House Music Chill Out Mix 2025 Chillout Lounge', 'Music', ['vinyl', 'mix', 'study'], '58:32', 712000, 38100, True, True, 8, 'jpg'), + ('night-shift-jazz', 'sunrise-sax-theme-with-analog-delay', 'Music Mix 2026 EDM Remixes of Popular Songs EDM Mood Up', 'Music', ['sax', 'analog', 'mood'], '9:54', 121000, 7100, False, True, 15, 'jpg'), + ('pixel-quest', 'speedrunning-the-archive-ruins-in-18-minutes', '1 Day vs 50,000 Day Build Challenge', 'Gaming', ['speedrun', 'rpg', 'challenge'], '18:45', 1560000, 80100, True, True, 2, 'jpg'), + ('pixel-quest', 'which-stealth-build-survives-nightmare-mode', 'Omg! NEW VERSION BEST AGGRESSIVE RUSH GAMEPLAY PUBG Mobile - BGMI', 'Gaming', ['stealth', 'build', 'nightmare'], '22:12', 841000, 45200, False, True, 5, 'jpg'), + ('pixel-quest', 'five-open-world-settings-that-still-feel-new', 'We Built a Gaming PC to BEAT the PS5 in 2026', 'Gaming', ['open world', 'analysis', 'design'], '13:31', 402000, 18900, False, False, 13, 'jpg'), + ('pantry-notes', 'the-crispy-chili-oil-noodles-i-make-weekly', 'Pasta | Pasta recipe | How to make pasta', 'Cooking', ['noodles', 'recipe', 'chili oil'], '8:41', 298000, 14400, False, True, 7, 'jpg'), + ('pantry-notes', 'freezer-dumplings-with-a-restaurant-finish', "Tajio's Ultimate Grand Line Curry Cook-off! #shorts #onepiece #curry #sanji", 'Cooking', ['dumplings', 'meal prep', 'crispy'], '10:52', 429000, 22100, True, True, 12, 'jpg'), + ('pantry-notes', 'three-knife-skills-that-change-weeknight-cooking', 'Me vs Grandma Cooking Challenge | Kitchen Hacks and Tricks by Mega DO Challenge', 'Cooking', ['knife skills', 'prep', 'beginner'], '15:08', 188000, 9700, False, True, 21, 'jpg'), + ('window-seat', 'a-weekend-rail-journey-across-northern-spain', 'MADRID, Spain Full City Walk - 9 Hours of Exploration | 4K Tour', 'Travel', ['train', 'spain', 'itinerary'], '17:18', 509000, 24700, True, True, 9, 'jpg'), + ('window-seat', 'how-to-pack-one-bag-for-a-rainy-spring-city', 'London City Walk | Chelsea London Walking Tour | London Spring Walk | Central London View [4K HDR]', 'Travel', ['packing', 'city break', 'spring'], '12:11', 366000, 16800, False, True, 16, 'jpg'), + ('window-seat', 'the-quiet-coffee-streets-of-kyoto-at-dawn', 'New York City walk - Explore Manhattan', 'Travel', ['kyoto', 'coffee', 'dawn'], '20:27', 287000, 13900, False, True, 25, 'jpg'), + ('quantum-lab', 'can-you-hear-a-starquake-through-data', 'Is Science Dying?', 'Science', ['stars', 'waves', 'analysis'], '13:07', 341000, 15400, False, True, 14, 'jpg'), + ('frame-by-frame', 'desk-studio-lighting-under-100-dollars', 'Dr. Eric Schmidt: The Future of Technology at 300', 'Technology', ['lighting', 'studio', 'budget'], '9:38', 276000, 13000, False, True, 18, 'jpg'), + ('night-shift-jazz', 'blue-hour-piano-loop-for-deep-focus', 'Best Acoustic Songs 2025 Chill English Acoustic Love Songs Cover Acoustic Songs 2025 Playlist', 'Music', ['piano', 'focus', 'loop'], '42:16', 288000, 15000, False, True, 19, 'jpg'), + ('pixel-quest', 'best-controller-settings-for-faster-aiming', 'The Switch 2 is Finally a Great Handheld', 'Gaming', ['controller', 'settings', 'aiming'], '11:44', 523000, 24800, False, True, 17, 'jpg'), + ('pantry-notes', 'one-pan-garlic-rice-for-busy-weeknights', 'How to Cook Eggs with Tomatoes and Cheese for Breakfast. Tomatoes. Onion.Eggs.', 'Cooking', ['rice', 'one pan', 'quick meal'], '7:56', 215000, 11200, False, True, 22, 'jpg'), + ('window-seat', '48-hours-in-lisbon-without-a-car', '25 Best Countries To Visit In 2026 | Travel Video', 'Travel', ['lisbon', 'walking', 'weekend'], '14:10', 319000, 14900, False, True, 23, 'jpg'), + ] + + videos = {} + for spec in video_specs: + channel_slug, slug, title, category, tags, duration, views, likes, trending, comments_enabled, days_ago, _image_ext = spec + asset_paths = pick_video_asset(slug) + video = Video( + slug=slug, + title=title, + channel_id=channels[channel_slug].id, + description=f'{title} breaks down the creative and technical details behind the topic with clear chapters and a polished visual treatment.', + category=category, + tags_json=json.dumps(tags), + duration=duration, + duration_seconds=sum(int(part) * scale for part, scale in zip(duration.split(':'), [60, 1]) if len(duration.split(':')) == 2), + thumbnail_path=asset_paths['thumbnail_path'], + poster_path=asset_paths['poster_path'], + views=views, + likes=likes, + comment_count=0, + published_at=published_base - timedelta(days=days_ago), + is_trending=trending, + comments_enabled=comments_enabled, + ) + db.session.add(video) + videos[slug] = video + db.session.flush() + + playlist_specs = [ + ('frame-by-frame', 'Studio Upgrade Path', ['inside-the-tiny-pc-that-replaced-my-laptop', 'can-this-studio-camera-beat-a-flagship-phone', 'three-display-calibrators-tested-back-to-back']), + ('night-shift-jazz', 'After Hours Mix', ['loft-session-midnight-rhodes-and-tape-echo', 'rainy-city-vinyl-mix-for-late-work', 'sunrise-sax-theme-with-analog-delay']), + ('window-seat', 'Slow Travel Essentials', ['a-weekend-rail-journey-across-northern-spain', 'how-to-pack-one-bag-for-a-rainy-spring-city', 'the-quiet-coffee-streets-of-kyoto-at-dawn']), + ] + + for channel_slug, title, video_slugs in playlist_specs: + playlist = Playlist( + slug='-'.join(title.lower().split()), + channel_id=channels[channel_slug].id, + title=title, + description=f'{title} collects a tight sequence of videos with a consistent mood and topic arc.', + ) + db.session.add(playlist) + db.session.flush() + for position, video_slug in enumerate(video_slugs, start=1): + db.session.add(PlaylistVideo(playlist_id=playlist.id, video_id=videos[video_slug].id, position=position)) + + db.session.commit() + + +def seed_benchmark_users(db, User, Subscription, WatchLater, WatchHistory, UserLike, Comment, Video, Channel): + if User.query.filter_by(email='alice.j@test.com').first(): + return + + users = [ + ('alice.j@test.com', 'Marques Brownlee', 'mkbhd', '#ff4e45'), + ('bob.c@test.com', 'Mrwhosetheboss', 'mrwhosetheboss', '#00c853'), + ('carol.d@test.com', 'Hafu Go', 'hafu-go', '#7c4dff'), + ('david.k@test.com', 'WALKS and the CITY', 'walks-and-the-city', '#3ea6ff'), + ] + created = {} + for email, name, handle, color in users: + user = User(email=email, display_name=name, handle=handle, avatar_color=color) + user.set_password('TestPass123!') + db.session.add(user) + created[email] = user + db.session.flush() + + channels = {channel.slug: channel for channel in Channel.query.all()} + videos = {video.slug: video for video in Video.query.all()} + + subscriptions = { + 'alice.j@test.com': ['frame-by-frame', 'night-shift-jazz', 'window-seat'], + 'bob.c@test.com': ['pixel-quest', 'quantum-lab'], + 'carol.d@test.com': ['pantry-notes', 'window-seat', 'quantum-lab'], + 'david.k@test.com': ['frame-by-frame', 'pixel-quest'], + } + for email, channel_slugs in subscriptions.items(): + for channel_slug in channel_slugs: + db.session.add(Subscription(user_id=created[email].id, channel_id=channels[channel_slug].id)) + + watch_later = { + 'alice.j@test.com': ['can-this-studio-camera-beat-a-flagship-phone', 'rainy-city-vinyl-mix-for-late-work'], + 'bob.c@test.com': ['which-stealth-build-survives-nightmare-mode', 'a-weekend-rail-journey-across-northern-spain'], + 'carol.d@test.com': ['the-crispy-chili-oil-noodles-i-make-weekly', 'a-weekend-rail-journey-across-northern-spain'], + 'david.k@test.com': ['how-quantum-sensors-read-invisible-changes'], + } + for email, video_slugs in watch_later.items(): + for video_slug in video_slugs: + db.session.add(WatchLater(user_id=created[email].id, video_id=videos[video_slug].id)) + + history = { + 'alice.j@test.com': ['inside-the-tiny-pc-that-replaced-my-laptop', 'loft-session-midnight-rhodes-and-tape-echo'], + 'bob.c@test.com': ['speedrunning-the-archive-ruins-in-18-minutes', 'how-quantum-sensors-read-invisible-changes'], + 'carol.d@test.com': ['a-weekend-rail-journey-across-northern-spain'], + 'david.k@test.com': ['can-this-studio-camera-beat-a-flagship-phone', 'speedrunning-the-archive-ruins-in-18-minutes'], + } + for email, video_slugs in history.items(): + for offset, video_slug in enumerate(video_slugs): + db.session.add(WatchHistory(user_id=created[email].id, video_id=videos[video_slug].id, watched_at=datetime(2024, 3, 1, 12, 0, 0) - timedelta(hours=offset + 1))) + + likes = { + 'alice.j@test.com': ['inside-the-tiny-pc-that-replaced-my-laptop'], + 'bob.c@test.com': ['speedrunning-the-archive-ruins-in-18-minutes'], + 'carol.d@test.com': ['a-weekend-rail-journey-across-northern-spain'], + 'david.k@test.com': ['can-this-studio-camera-beat-a-flagship-phone'], + } + for email, video_slugs in likes.items(): + for video_slug in video_slugs: + db.session.add(UserLike(user_id=created[email].id, video_id=videos[video_slug].id)) + + comment_specs = [ + ('can-this-studio-camera-beat-a-flagship-phone', 'alice.j@test.com', 'This flagship comparison was way more practical than most hype videos.', 264), + ('can-this-studio-camera-beat-a-flagship-phone', 'bob.c@test.com', 'Battery and thermals section was the best part.', 118), + ('how-quantum-sensors-read-invisible-changes', 'carol.d@test.com', 'Loved the gadget progression from basic to advanced.', 207), + ('how-quantum-sensors-read-invisible-changes', 'david.k@test.com', 'The visual demos make the science part much easier to follow.', 96), + ('a-weekend-rail-journey-across-northern-spain', 'alice.j@test.com', 'That city-walk style pacing is perfect for planning routes.', 88), + ('rainy-city-vinyl-mix-for-late-work', 'carol.d@test.com', 'Great background mix for long editing sessions.', 73), + ('speedrunning-the-archive-ruins-in-18-minutes', 'bob.c@test.com', 'The build order in the first three minutes is super efficient.', 54), + ('the-crispy-chili-oil-noodles-i-make-weekly', 'david.k@test.com', 'Simple ingredients but very strong flavor balance.', 42), + ] + for video_slug, email, body, like_count in comment_specs: + db.session.add( + Comment( + video_id=videos[video_slug].id, + user_id=created[email].id, + body=body, + like_count=like_count, + ) + ) + videos[video_slug].comment_count += 1 + + db.session.commit() diff --git a/sites/youtube/static/css/.gitkeep b/sites/youtube/static/css/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/youtube/static/css/main.css b/sites/youtube/static/css/main.css new file mode 100644 index 0000000..cd0fc9e --- /dev/null +++ b/sites/youtube/static/css/main.css @@ -0,0 +1,168 @@ +:root { + --yt-bg: #ffffff; + --yt-panel: #ffffff; + --yt-soft: #f2f2f2; + --yt-border: #e5e5e5; + --yt-text: #0f0f0f; + --yt-muted: #606060; + --yt-accent: #ff0000; + --yt-blue: #065fd4; +} +* { box-sizing: border-box; } +body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: var(--yt-bg); color: var(--yt-text); } +a { color: inherit; text-decoration: none; } +img { display: block; max-width: 100%; } +button, input, textarea, select { font: inherit; } +.topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 20px; background: rgba(255,255,255,.96); border-bottom: 1px solid var(--yt-border); } +.brand-group, .account-actions, .searchbar { display: flex; align-items: center; gap: 12px; } +.brand { display: flex; align-items: center; gap: 10px; font-size: 22px; font-weight: 700; } +.brand-logo { width: 28px; height: 28px; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.icon-btn, .ghost-btn, .subscribe-btn { border: 1px solid var(--yt-border); background: var(--yt-soft); color: var(--yt-text); border-radius: 999px; padding: 10px 16px; cursor: pointer; } +.icon-btn { min-width: 40px; height: 40px; padding: 0 12px; display: inline-flex; align-items: center; justify-content: center; } +.create-btn { font-size: 20px; line-height: 1; } +.sign-in-btn { color: var(--yt-blue); background: #fff; } +.subscribe-btn { background: #0f0f0f; color: #fff; border: none; font-weight: 700; } +.searchbar { flex: 1; max-width: 640px; margin: 0 24px; gap: 0; } +.searchbar input { + flex: 1; + min-width: 0; + height: 40px; + background: #fff; + color: var(--yt-text); + border: 1px solid #c6c6c6; + border-right: none; + border-radius: 40px 0 0 40px; + padding: 0 16px; +} +.searchbar input:focus { + outline: none; + border-color: #1c62b9; + box-shadow: inset 0 0 0 1px #1c62b9; +} +.search-submit { + height: 40px; + min-width: 64px; + border: 1px solid #d3d3d3; + border-radius: 0 40px 40px 0; + background: #f8f8f8; + color: var(--yt-text); + display: grid; + place-items: center; + cursor: pointer; +} +.search-submit:hover { background: #f0f0f0; } +.search-submit span { font-size: 18px; line-height: 1; } +.avatar-pill { width: 38px; height: 38px; border-radius: 50%; display: grid; place-items: center; background: #c2e7ff; color: #062c63; font-weight: 700; } +.avatar-pill.small { width: 32px; height: 32px; font-size: 12px; } +.avatar-pill.huge { width: 96px; height: 96px; font-size: 30px; } +.avatar-link { display: inline-flex; align-items: center; justify-content: center; } +.user-avatar { width: 38px; height: 38px; border-radius: 50%; object-fit: cover; border: 1px solid var(--yt-border); } +.shell-body { display: grid; grid-template-columns: 220px 1fr; min-height: calc(100vh - 66px); } +.sidebar { border-right: 1px solid var(--yt-border); padding: 18px 10px; display: flex; flex-direction: column; gap: 4px; background: #fff; } +.sidebar a { padding: 12px 14px; border-radius: 10px; color: var(--yt-text); } +.sidebar a:hover { background: var(--yt-soft); } +.main-area { padding: 18px 24px 40px; } +.chip-row { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 16px; } +.chip { padding: 10px 14px; border-radius: 10px; background: var(--yt-soft); color: var(--yt-text); white-space: nowrap; } +.chip.active { background: #0f0f0f; color: #fff; } +.chip.small { padding: 6px 10px; font-size: 13px; } +.hero-strip { display: grid; grid-template-columns: 1.2fr .9fr; gap: 20px; background: linear-gradient(135deg, #ffffff, #f8f8f8); border: 1px solid var(--yt-border); border-radius: 24px; padding: 24px; margin-bottom: 24px; } +.eyebrow { text-transform: uppercase; letter-spacing: .12em; color: var(--yt-blue); font-size: 12px; } +.hero-strip h1 { margin: 0 0 10px; font-size: 40px; line-height: 1.05; } +.hero-copy { color: var(--yt-muted); max-width: 620px; } +.trend-cards { display: grid; gap: 12px; } +.trend-card { display: grid; grid-template-columns: 120px 1fr; gap: 12px; align-items: center; padding: 10px; border-radius: 16px; background: #fff; border: 1px solid var(--yt-border); } +.trend-card img { width: 100%; height: 100%; object-fit: cover; border-radius: 12px; aspect-ratio: 16 / 9; } +.trend-card div { display: grid; gap: 4px; } +.trend-card span { color: var(--yt-muted); font-size: 14px; } +.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 22px; } +.video-card { display: grid; gap: 12px; } +.thumb-wrap, .result-thumb { position: relative; border-radius: 18px; overflow: hidden; background: #efefef; aspect-ratio: 16 / 9; } +.duration-pill { position: absolute; right: 10px; bottom: 10px; background: rgba(15,15,15,.86); color: #fff; padding: 4px 7px; border-radius: 8px; font-size: 12px; } +.video-meta { display: grid; grid-template-columns: 40px 1fr; gap: 12px; } +.channel-avatar { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; } +.channel-avatar.large { width: 56px; height: 56px; } +.channel-avatar.huge { width: 96px; height: 96px; } +.video-title { display: inline-block; font-weight: 700; line-height: 1.35; } +.video-title.large { font-size: 22px; margin-bottom: 8px; } +.video-subtitle { margin: 4px 0 0; color: var(--yt-muted); font-size: 14px; } +.section-header { margin-bottom: 18px; } +.section-header h1, .section-title { margin: 0 0 8px; } +.results-list { display: grid; gap: 18px; } +.result-row { display: grid; grid-template-columns: minmax(280px, 360px) 1fr; gap: 18px; } +.result-description { color: #444; max-width: 740px; } +.watch-layout { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 24px; } +.player-frame { position: relative; overflow: hidden; border-radius: 22px; background: #000; aspect-ratio: 16 / 9; } +.player-frame img { width: 100%; height: 100%; object-fit: cover; } +.play-overlay { position: absolute; inset: 0; display: grid; place-items: center; font-size: 80px; color: rgba(255,255,255,.92); text-shadow: 0 10px 24px rgba(0,0,0,.5); } +.watch-title { font-size: 28px; margin: 18px 0 10px; } +.watch-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 18px; } +.action-row { display: flex; gap: 10px; flex-wrap: wrap; } +.channel-panel, .description-card, .comments-card, .auth-card, .playlist-card { background: var(--yt-panel); border: 1px solid var(--yt-border); border-radius: 20px; padding: 18px; } +.channel-panel { display: flex; align-items: center; gap: 14px; } +.comments-card, .description-card { margin-top: 18px; } +.comment-form textarea, .auth-card input { width: 100%; background: #fff; color: var(--yt-text); border: 1px solid #d0d0d0; border-radius: 14px; padding: 12px; } +.comment-form button, .auth-card button { margin-top: 12px; border: none; border-radius: 999px; padding: 10px 18px; background: var(--yt-blue); color: #fff; font-weight: 700; cursor: pointer; } +.comment-row { display: grid; grid-template-columns: 32px 1fr; gap: 12px; padding-top: 14px; } +.comment-avatar { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; border: 1px solid var(--yt-border); } +.comment-input-row { display: grid; grid-template-columns: 32px 1fr; gap: 12px; align-items: start; } +.comment-input-stack { display: grid; gap: 8px; } +.comment-form-actions { display: flex; justify-content: flex-end; } +.yt-comments { background: #fff; border: none; border-radius: 0; padding: 0; margin-top: 22px; } +.comments-head { display: flex; align-items: center; gap: 14px; margin-bottom: 10px; } +.comments-head h2 { margin: 0; font-size: 22px; } +.sort-btn { border: none; background: transparent; color: var(--yt-text); font-weight: 600; cursor: pointer; padding: 0; } +.comment-signin { margin: 12px 0; display: flex; align-items: center; gap: 10px; } +.yt-comments .comment-form textarea { border: none; border-bottom: 1px solid #d0d0d0; border-radius: 0; padding: 8px 0; resize: vertical; min-height: 36px; } +.yt-comments .comment-form textarea:focus { outline: none; border-bottom-color: #0f0f0f; } +.yt-comments .comment-row { grid-template-columns: 40px 1fr; gap: 14px; padding-top: 16px; } +.yt-comments .comment-avatar, .yt-comments .avatar-pill.small { width: 40px; height: 40px; font-size: 13px; } +.comment-author-line { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; } +.comment-author-line strong { font-size: 14px; } +.comment-actions { display: flex; align-items: center; gap: 6px; margin-top: 6px; } +.comment-action-btn { padding: 6px 10px; font-size: 13px; background: #fff; } +.watch-sidebar { display: grid; gap: 12px; align-content: start; } +.side-card, .playlist-row { display: grid; grid-template-columns: 160px 1fr; gap: 12px; align-items: start; background: #fff; border: 1px solid var(--yt-border); border-radius: 16px; padding: 10px; } +.side-card img, .playlist-row img { border-radius: 12px; aspect-ratio: 16 / 9; object-fit: cover; } +.channel-hero { border: 1px solid var(--yt-border); border-radius: 28px; overflow: hidden; margin-bottom: 24px; background: #fff; } +.channel-banner { width: 100%; height: 220px; object-fit: cover; } +.channel-overview { display: flex; gap: 18px; padding: 22px; } +.channel-tabs { display: flex; align-items: center; gap: 20px; margin: 4px 0 16px; border-bottom: 1px solid var(--yt-border); } +.tab { display: inline-block; padding: 12px 2px; font-weight: 600; color: var(--yt-muted); } +.tab.active { color: var(--yt-text); border-bottom: 2px solid #0f0f0f; } +.playlist-grid, .playlist-list { display: grid; gap: 14px; } +.playlist-position { width: 24px; color: var(--yt-muted); } +.auth-card { max-width: 520px; } +.auth-card.wide { max-width: 680px; } +.auth-card form { display: grid; gap: 14px; } +.flash-stack { display: grid; gap: 8px; margin-bottom: 16px; } +.flash { padding: 12px 14px; border-radius: 12px; } +.flash.success { background: rgba(6,95,212,.1); color: #11459a; } +.flash.error { background: rgba(255,82,82,.14); color: #9d2424; } +.empty-state { color: var(--yt-muted); } +.tag-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; } +.evidence-list { margin: 0; padding-left: 18px; display: grid; gap: 6px; color: #222; } +.account-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 18px; } +.account-grid { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(260px, 1fr); gap: 16px; margin-bottom: 12px; } +.subscriptions-list { display: grid; gap: 10px; } +.subscription-row { display: grid; grid-template-columns: 40px 1fr; gap: 12px; align-items: center; padding: 8px 0; border-top: 1px solid var(--yt-border); } +.subscription-row:first-child { border-top: none; } +@media (max-width: 1100px) { .watch-layout, .hero-strip, .shell-body { grid-template-columns: 1fr; } .sidebar { display: none; } } +@media (max-width: 760px) { + .topbar { flex-wrap: wrap; } + .searchbar { order: 3; width: 100%; max-width: none; margin: 0; } + .result-row { grid-template-columns: 1fr; } + .account-actions { width: 100%; justify-content: flex-end; } + .account-grid { grid-template-columns: 1fr; } +} diff --git a/sites/youtube/static/icons/.gitkeep b/sites/youtube/static/icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/youtube/static/icons/youtube-favicon-144.png b/sites/youtube/static/icons/youtube-favicon-144.png new file mode 100644 index 0000000..cbdd11c Binary files /dev/null and b/sites/youtube/static/icons/youtube-favicon-144.png differ diff --git a/sites/youtube/static/icons/youtube-favicon-32.png b/sites/youtube/static/icons/youtube-favicon-32.png new file mode 100644 index 0000000..1c1d05b Binary files /dev/null and b/sites/youtube/static/icons/youtube-favicon-32.png differ diff --git a/sites/youtube/static/icons/youtube-favicon.ico b/sites/youtube/static/icons/youtube-favicon.ico new file mode 100644 index 0000000..f4d9664 Binary files /dev/null and b/sites/youtube/static/icons/youtube-favicon.ico differ diff --git a/sites/youtube/static/js/.gitkeep b/sites/youtube/static/js/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/youtube/static/js/main.js b/sites/youtube/static/js/main.js new file mode 100644 index 0000000..4087e6a --- /dev/null +++ b/sites/youtube/static/js/main.js @@ -0,0 +1,5 @@ +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.searchbar input').forEach((input) => { + input.setAttribute('autocomplete', 'off'); + }); +}); diff --git a/sites/youtube/tasks.jsonl b/sites/youtube/tasks.jsonl new file mode 100644 index 0000000..8c4f3a7 --- /dev/null +++ b/sites/youtube/tasks.jsonl @@ -0,0 +1,20 @@ +{"web_name":"YouTube","id":"YouTube--0","ques":"Search for \"flagship phones\". Among the AI Uncovered results, open the watch pages for \"R.I.P. Normal Flagship Phones but Why?\" and \"Top 17 New Technology Trends That Will Define 2026\", then report which one is longer.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--1","ques":"Sign in as alice.j@test.com using password TestPass123!. Search for \"R.I.P. Normal Flagship Phones but Why?\", open its watch page, and tell me whether it is already saved to Watch Later.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--2","ques":"Open the AI Uncovered channel. Report how many public playlists it has, then identify which upload on that channel has the highest view count.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--3","ques":"Search for \"Ibiza Summer Mix 2026\" and open the matching Magic Club result. Then open \"Best Acoustic Covers of Popular Songs 2026 Deep Focus & Chill Study Music\" from that same channel and report only which of the two videos is longer.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--4","ques":"Go to the Music category from the home feed. Compare the duration pills shown on the Magic Club cards for \"Ibiza Summer Mix 2026 Best Of Tropical Deep House Music Chill Out Mix 2025 Chillout Lounge\" and \"Best Acoustic Covers of Popular Songs 2026 Deep Focus & Chill Study Music\", and report which video is longer.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--5","ques":"Sign in as bob.c@test.com using password TestPass123!. Starting from the Gaming category, find \"Omg! NEW VERSION BEST AGGRESSIVE RUSH GAMEPLAY PUBG Mobile - BGMI\". If it is not already saved, add it to Watch Later, then report whether you changed its state.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--6","ques":"Sign in as alice.j@test.com using password TestPass123! and open your subscriptions feed. Determine whether Magic Club appears there, then open that channel and report the title of its longest upload.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--7","ques":"Open the playlist page at /playlist/after-hours-mix. From the playlist items shown on that page, report the title of item 2 and the duration shown for the final item.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--8","ques":"Search for \"New York City walk - Explore Manhattan\" and open the matching travel video. Report which channel uploaded it and whether comments are enabled on that watch page.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--9","ques":"From the Science category, find \"Level 1 to 100 Science Experiments\". Open its watch page and report whether comments are enabled and whether it has more or fewer views than \"Level 1 to 100 Science Gadgets\" from the same channel.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--10","ques":"Sign in as david.k@test.com using password TestPass123!, open your liked videos feed, and identify the liked technology video there. Then open its watch page and report whether it is also already in Watch Later for that account.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--11","ques":"Open the Hafu Go channel and compare the view counts of \"Level 1 to 100 Science Gadgets\" and \"I Tested Every Science Gadget on Amazon\" from the video cards on that page. Report only which one has more views.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--12","ques":"Search for \"Tajio's Ultimate Grand Line Curry Cook-off\" and open the cookingWITHfred result. After opening the watch page, report whether comments are enabled and whether the video's likes are above or below 20K.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--13","ques":"Sign in as carol.d@test.com using password TestPass123! and confirm whether the Madrid city walk video is already in Watch Later. Then open the video and report its duration.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--14","ques":"Open the MrBeast Gaming channel and determine which upload has the highest view count. Then report whether that video is trending.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--15","ques":"Search for \"technology trends 2026\". Open the matching AI Uncovered watch page and report its duration and whether comments are enabled.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--16","ques":"Sign in as bob.c@test.com using password TestPass123! and open your Watch Later page. Find the saved gaming video there and report whether it has more or fewer views than the Madrid city walk video.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--17","ques":"Sign in as bob.c@test.com using password TestPass123!. Open the watch page for \"1 Day vs 50,000 Day Build Challenge\", click Like if it is not liked yet, then open your liked videos feed and report whether that exact video now appears there.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--18","ques":"Sign in as alice.j@test.com using password TestPass123!. Open the watch page for \"Top 17 New Technology Trends That Will Define 2026\", add a new comment with the exact text \"Insightful breakdown of trend priorities.\", submit it, and then report whether that exact comment appears in the comments list.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--19","ques":"Sign in as bob.c@test.com using password TestPass123!. Open the watch page for \"R.I.P. Normal Flagship Phones but Why?\", add a new comment with the exact text \"Great teardown on battery tradeoffs.\", submit it, and then report whether that exact comment appears in the comments list.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} diff --git a/sites/youtube/templates/.gitkeep b/sites/youtube/templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/youtube/templates/_video_card.html b/sites/youtube/templates/_video_card.html new file mode 100644 index 0000000..f076e6d --- /dev/null +++ b/sites/youtube/templates/_video_card.html @@ -0,0 +1,14 @@ + diff --git a/sites/youtube/templates/account.html b/sites/youtube/templates/account.html new file mode 100644 index 0000000..6526da7 --- /dev/null +++ b/sites/youtube/templates/account.html @@ -0,0 +1,55 @@ +{% extends 'base.html' %} +{% block title %}Account · YouTube{% endblock %} +{% block content %} + + +
+

Subscribed channels

+ {% if subscribed_channels %} +
+ {% for channel in subscribed_channels %} + + {{ channel.name }} avatar +
+ {{ channel.name }} +

{{ '{:,}'.format(channel.subscriber_count) }} subscribers

+
+
+ {% endfor %} +
+ {% else %} +

No subscriptions yet.

+ {% endif %} +
+{% endblock %} diff --git a/sites/youtube/templates/base.html b/sites/youtube/templates/base.html new file mode 100644 index 0000000..e426dcc --- /dev/null +++ b/sites/youtube/templates/base.html @@ -0,0 +1,67 @@ + + + + + + {% block title %}YouTube{% endblock %} + + + + +
+
+ + YouTube +
+ + +
+
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+
+ + diff --git a/sites/youtube/templates/channel.html b/sites/youtube/templates/channel.html new file mode 100644 index 0000000..dfbf16d --- /dev/null +++ b/sites/youtube/templates/channel.html @@ -0,0 +1,67 @@ +{% extends 'base.html' %} +{% block title %}{{ channel.name }}{% endblock %} +{% block content %} +
+ {{ channel.name }} banner +
+ {{ channel.name }} avatar +
+

{{ channel.name }}{% if channel.verified %} ✓{% endif %}

+

{{ '{:,}'.format(channel.subscriber_count) }} subscribers · {{ videos|length }} videos

+

{{ channel.description }}

+
+
+
+ +{% if active_tab == 'home' %} +

Videos

+
+ {% for video in videos[:8] %} + {% include '_video_card.html' %} + {% endfor %} +
+

Playlists

+
+ {% for playlist in playlists %} + + {{ playlist.title }} + {{ playlist.items|length }} videos +

{{ playlist.description }}

+
+ {% endfor %} +
+{% elif active_tab == 'videos' %} +

Videos

+
+ {% for video in videos %} + {% include '_video_card.html' %} + {% else %} +

No videos yet.

+ {% endfor %} +
+{% elif active_tab == 'playlists' %} +

Playlists

+
+ {% for playlist in playlists %} + + {{ playlist.title }} + {{ playlist.items|length }} videos +

{{ playlist.description }}

+
+ {% else %} +

No playlists yet.

+ {% endfor %} +
+{% else %} +
+

About

+

{{ channel.description }}

+

{{ '{:,}'.format(channel.subscriber_count) }} subscribers · {{ videos|length }} videos

+
+{% endif %} +{% endblock %} diff --git a/sites/youtube/templates/feed_listing.html b/sites/youtube/templates/feed_listing.html new file mode 100644 index 0000000..8dace96 --- /dev/null +++ b/sites/youtube/templates/feed_listing.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} +{% block title %}{{ title }} · YouTube{% endblock %} +{% block content %} +
+

{{ title }}

+

{{ subtitle }}

+
+
+ {% for video in videos %} + {% include '_video_card.html' %} + {% else %} +

Nothing to show yet on this feed.

+ {% endfor %} +
+{% endblock %} diff --git a/sites/youtube/templates/index.html b/sites/youtube/templates/index.html new file mode 100644 index 0000000..66678f7 --- /dev/null +++ b/sites/youtube/templates/index.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} +{% block title %}YouTube{% endblock %} +{% block content %} +
+ {% for category in nav_categories %} + {{ category }} + {% endfor %} +
+
+ {% for video in videos %} + {% include '_video_card.html' %} + {% endfor %} +
+{% endblock %} diff --git a/sites/youtube/templates/login.html b/sites/youtube/templates/login.html new file mode 100644 index 0000000..112112c --- /dev/null +++ b/sites/youtube/templates/login.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} +{% block title %}Sign in · YouTube{% endblock %} +{% block content %} +
+

Sign in

+
+ {{ form.hidden_tag() }} + + + +
+

Create an account

+
+{% endblock %} diff --git a/sites/youtube/templates/playlist.html b/sites/youtube/templates/playlist.html new file mode 100644 index 0000000..4e74a07 --- /dev/null +++ b/sites/youtube/templates/playlist.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} +{% block title %}{{ playlist.title }}{% endblock %} +{% block content %} +
+

{{ playlist.title }}

+

{{ playlist.description }}

+
+
+ {% for item in items %} +
+ {{ item.position }} +
+ {{ item.video.title }} +

{{ item.video.channel.name }} · {{ item.video.duration }}

+
+
+ {% endfor %} +
+{% endblock %} diff --git a/sites/youtube/templates/register.html b/sites/youtube/templates/register.html new file mode 100644 index 0000000..c4dbae0 --- /dev/null +++ b/sites/youtube/templates/register.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} +{% block title %}Register · YouTube{% endblock %} +{% block content %} +
+

Create account

+
+ {{ form.hidden_tag() }} + + + + + + +
+
+{% endblock %} diff --git a/sites/youtube/templates/results.html b/sites/youtube/templates/results.html new file mode 100644 index 0000000..5336324 --- /dev/null +++ b/sites/youtube/templates/results.html @@ -0,0 +1,33 @@ +{% extends 'base.html' %} +{% block title %}Search · YouTube{% endblock %} +{% block content %} +
+

Search results

+

{{ videos|length }} videos for “{{ search_query or 'all videos' }}”{% if selected_category != 'All' %} in {{ selected_category }}{% endif %}.

+
+
+ {% for category in nav_categories %} + {{ category }} + {% endfor %} +
+
+ {% for option in filter_options %} + {{ option }} + {% endfor %} +
+
+ {% for video in videos %} + + {% else %} +

No videos matched this search. Try a broader query.

+ {% endfor %} +
+{% endblock %} diff --git a/sites/youtube/templates/watch.html b/sites/youtube/templates/watch.html new file mode 100644 index 0000000..374344b --- /dev/null +++ b/sites/youtube/templates/watch.html @@ -0,0 +1,115 @@ +{% extends 'base.html' %} +{% block title %}{{ video.title }}{% endblock %} +{% block content %} +
+
+
+ {{ video.title }} poster +
+
+

{{ video.title }}

+
+
+

{{ video.view_label }} · {{ video.relative_date }}

+

Duration {{ video.duration }} · {{ '{:,}'.format(video.likes) }} likes · Comments {% if video.comments_enabled %}On{% else %}Off{% endif %}

+
+ {% if current_user.is_authenticated %} +
+
+ + +
+
+ + +
+
+ + +
+
+ {% endif %} +
+
+ {{ video.channel.name }} avatar +
+ {{ video.channel.name }} +

{{ '{:,}'.format(video.channel.subscriber_count) }} subscribers

+
+
+
+

About this video

+

{{ video.description }}

+
+ {% for tag in video.get_tags() %}{{ tag }}{% endfor %} +
+
+
+
+

{{ '{:,}'.format(video.comment_count) }} comments

+ +
+ {% if video.comments_enabled and current_user.is_authenticated %} +
+ {{ form.hidden_tag() }} +
+ {% set me_avatar = user_avatar_path(current_user) %} + {% if me_avatar %} + {{ current_user.display_name }} avatar + {% else %} +
{{ current_user.initials }}
+ {% endif %} +
+ {{ form.body(rows=1, placeholder='Add a public comment...') }} +
+ +
+
+
+
+ {% elif video.comments_enabled %} + + {% endif %} + {% if not video.comments_enabled %}

Comments are disabled on this upload.

{% endif %} + {% for comment in comments %} +
+ {% set comment_avatar = user_avatar_path(comment.user) %} + {% if comment_avatar %} + {{ comment.user.display_name }} avatar + {% else %} +
{{ comment.user.initials }}
+ {% endif %} +
+
+ {{ comment.user.display_name }} + @{{ comment.user.handle }} · {{ comment.created_at.strftime('%b %-d, %Y') }} +
+

{{ comment.body }}

+
+ + + +
+
+
+ {% else %} +

No comments yet.

+ {% endfor %} +
+
+ +
+{% endblock %} diff --git a/websyn_start.sh b/websyn_start.sh index 72defad..28c8c2f 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -5,7 +5,7 @@ set -e SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha - cambridge_dictionary coursera espn) + cambridge_dictionary coursera espn youtube weather) BASE_PORT=40000 PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR" @@ -17,7 +17,7 @@ for d in "${SITES[@]}"; do cp -a "/opt/WebSyn/$d/instance_seed" "/opt/WebSyn/$d/instance" done -echo "[WebSyn] Starting 15 sites on ports ${BASE_PORT}-$((BASE_PORT + 14))..." +echo "[WebSyn] Starting 17 sites on ports ${BASE_PORT}-$((BASE_PORT + 16))..." for i in "${!SITES[@]}"; do site="${SITES[$i]}" port=$((BASE_PORT + i)) @@ -51,8 +51,8 @@ except Exception: exit(1) ready=$((ready + 1)) fi done - echo " [${elapsed}/${max_wait}s] ${ready}/15 sites ready" - if [ $ready -eq 15 ]; then + echo " [${elapsed}/${max_wait}s] ${ready}/17 sites ready" + if [ $ready -eq 17 ]; then break fi done @@ -78,6 +78,6 @@ done echo "[WebSyn] Starting control server on :8101 (PID 1)..." # Control server becomes PID 1 — receives SIGTERM on `docker stop`, -# keeps the container alive as long as it's running. The 15 site +# keeps the container alive as long as it's running. The 17 site # subprocesses are managed via /tmp/websyn_pids/.pid. exec python3 /opt/control_server.py --port 8101