From c6fa841759bb6b80e3b90c4c8d29a508095fa140 Mon Sep 17 00:00:00 2001 From: YurunChen <1657503372@qq.com> Date: Thu, 14 May 2026 18:37:40 +0800 Subject: [PATCH 1/3] Add weather and youtube mirror sites with DB-backed seeded assets. Registers both sites in the runtime/control plane and includes templates, static UI, and seed logic that no longer depends on runtime manifest JSON files. Co-authored-by: Cursor --- .gitignore | 7 +- Dockerfile | 4 +- control_server.py | 2 +- sites/weather/_health.py | 4 + sites/weather/app.py | 619 ++++++++++++++++++ sites/weather/requirements.txt | 7 + sites/weather/seed_data.py | 227 +++++++ sites/weather/static/css/.gitkeep | 0 sites/weather/static/css/main.css | 218 ++++++ sites/weather/static/icons/.gitkeep | 0 .../static/icons/weather-apple-180.png | Bin 0 -> 15187 bytes .../static/icons/weather-favicon-32.png | Bin 0 -> 2384 bytes .../weather/static/icons/weather-favicon.ico | Bin 0 -> 1150 bytes .../weather/static/icons/weather-logo-og.png | Bin 0 -> 4514 bytes sites/weather/static/js/.gitkeep | 0 sites/weather/static/js/main.js | 5 + sites/weather/tasks.jsonl | 20 + sites/weather/templates/.gitkeep | 0 sites/weather/templates/account.html | 56 ++ sites/weather/templates/alerts.html | 86 +++ sites/weather/templates/base.html | 104 +++ sites/weather/templates/forecast_10day.html | 100 +++ sites/weather/templates/hourly.html | 97 +++ sites/weather/templates/index.html | 141 ++++ sites/weather/templates/location.html | 169 +++++ sites/weather/templates/login.html | 14 + sites/weather/templates/radar.html | 85 +++ sites/weather/templates/register.html | 15 + sites/weather/templates/search.html | 35 + sites/youtube/_health.py | 4 + sites/youtube/app.py | 507 ++++++++++++++ sites/youtube/requirements.txt | 7 + sites/youtube/seed_data.py | 230 +++++++ sites/youtube/static/css/.gitkeep | 0 sites/youtube/static/css/main.css | 139 ++++ sites/youtube/static/icons/.gitkeep | 0 .../static/icons/youtube-favicon-144.png | Bin 0 -> 5020 bytes .../static/icons/youtube-favicon-32.png | Bin 0 -> 3214 bytes .../youtube/static/icons/youtube-favicon.ico | Bin 0 -> 1150 bytes sites/youtube/static/js/.gitkeep | 0 sites/youtube/static/js/main.js | 5 + sites/youtube/tasks.jsonl | 19 + sites/youtube/templates/.gitkeep | 0 sites/youtube/templates/_video_card.html | 14 + sites/youtube/templates/account.html | 25 + sites/youtube/templates/base.html | 60 ++ sites/youtube/templates/channel.html | 39 ++ sites/youtube/templates/feed_listing.html | 15 + sites/youtube/templates/index.html | 32 + sites/youtube/templates/login.html | 14 + sites/youtube/templates/playlist.html | 27 + sites/youtube/templates/register.html | 16 + sites/youtube/templates/results.html | 28 + sites/youtube/templates/watch.html | 84 +++ websyn_start.sh | 10 +- 55 files changed, 3281 insertions(+), 9 deletions(-) create mode 100644 sites/weather/_health.py create mode 100644 sites/weather/app.py create mode 100644 sites/weather/requirements.txt create mode 100644 sites/weather/seed_data.py create mode 100644 sites/weather/static/css/.gitkeep create mode 100644 sites/weather/static/css/main.css create mode 100644 sites/weather/static/icons/.gitkeep create mode 100644 sites/weather/static/icons/weather-apple-180.png create mode 100644 sites/weather/static/icons/weather-favicon-32.png create mode 100644 sites/weather/static/icons/weather-favicon.ico create mode 100644 sites/weather/static/icons/weather-logo-og.png create mode 100644 sites/weather/static/js/.gitkeep create mode 100644 sites/weather/static/js/main.js create mode 100644 sites/weather/tasks.jsonl create mode 100644 sites/weather/templates/.gitkeep create mode 100644 sites/weather/templates/account.html create mode 100644 sites/weather/templates/alerts.html create mode 100644 sites/weather/templates/base.html create mode 100644 sites/weather/templates/forecast_10day.html create mode 100644 sites/weather/templates/hourly.html create mode 100644 sites/weather/templates/index.html create mode 100644 sites/weather/templates/location.html create mode 100644 sites/weather/templates/login.html create mode 100644 sites/weather/templates/radar.html create mode 100644 sites/weather/templates/register.html create mode 100644 sites/weather/templates/search.html create mode 100644 sites/youtube/_health.py create mode 100644 sites/youtube/app.py create mode 100644 sites/youtube/requirements.txt create mode 100644 sites/youtube/seed_data.py create mode 100644 sites/youtube/static/css/.gitkeep create mode 100644 sites/youtube/static/css/main.css create mode 100644 sites/youtube/static/icons/.gitkeep create mode 100644 sites/youtube/static/icons/youtube-favicon-144.png create mode 100644 sites/youtube/static/icons/youtube-favicon-32.png create mode 100644 sites/youtube/static/icons/youtube-favicon.ico create mode 100644 sites/youtube/static/js/.gitkeep create mode 100644 sites/youtube/static/js/main.js create mode 100644 sites/youtube/tasks.jsonl create mode 100644 sites/youtube/templates/.gitkeep create mode 100644 sites/youtube/templates/_video_card.html create mode 100644 sites/youtube/templates/account.html create mode 100644 sites/youtube/templates/base.html create mode 100644 sites/youtube/templates/channel.html create mode 100644 sites/youtube/templates/feed_listing.html create mode 100644 sites/youtube/templates/index.html create mode 100644 sites/youtube/templates/login.html create mode 100644 sites/youtube/templates/playlist.html create mode 100644 sites/youtube/templates/register.html create mode 100644 sites/youtube/templates/results.html create mode 100644 sites/youtube/templates/watch.html diff --git a/.gitignore b/.gitignore index c2efc04..806bfc7 100644 --- a/.gitignore +++ b/.gitignore @@ -92,4 +92,9 @@ secrets.json # ============================================================ # Agent demo results # ============================================================= -agent_demo/runs/ \ No newline at end of file +agent_demo/runs/ + +# ============================================================ +# Local-only scratch/test artifacts +# ============================================================= +.local_ignore/ \ No newline at end of file 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..f1501f7 --- /dev/null +++ b/sites/weather/app.py @@ -0,0 +1,619 @@ +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') + + +def mirror_now() -> datetime: + return MIRROR_REFERENCE_DATE + + +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': 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) + + +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, + } + + +def homepage_story_data(): + scraped_cards = [ + {'title': row.title, 'image': 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('/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() + return render_template('account.html', saved=saved) + + +@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..464cb9b --- /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.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..caea5f1 --- /dev/null +++ b/sites/weather/static/css/main.css @@ -0,0 +1,218 @@ +: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; +} 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 0000000000000000000000000000000000000000..be5e5ae949a9d6474614a670aeaf82a59f4cacd2 GIT binary patch literal 15187 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6c612dd42403ea9857e3f5efe8d469a8abe301d3 GIT binary patch literal 2384 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e6aaed0ac9bb0e98f0595c29c1bd13d72e119ad2 GIT binary patch literal 4514 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 literal 0 HcmV?d00001 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..7b93e12 --- /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 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 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 and identify both 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, 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, 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..e1736b1 --- /dev/null +++ b/sites/weather/templates/account.html @@ -0,0 +1,56 @@ +{% 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..26d0edd --- /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..b771687 --- /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/forecast_10day.html b/sites/weather/templates/forecast_10day.html new file mode 100644 index 0000000..25a56e6 --- /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 }} +
+
{{ day.high_f }}ยฐ{{ 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..f8f6e63 --- /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') }} +
{{ hour.temperature_f }}ยฐ
+

{{ hour.condition_label }}

+
+ Rain {{ hour.precip_pct }}% + Wind {{ hour.wind_mph }} 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..93a2795 --- /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 +
+
+
+
{{ lead_condition.temperature_f }}ยฐ
+
{{ lead_condition.condition_label }}
+
Day {{ forecast_hint.high_f if forecast_hint else lead_condition.temperature_f + 4 }}ยฐ โ€ข Night {{ 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 }}

+
{{ spotlight_modules.flu_module.value }}ยฐ
+

{{ spotlight_modules.flu_module.description }}

+
+
+

Outdoor Activity

+

{{ spotlight_modules.outdoor_module.label }}

+
{{ 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..9c6ec3b --- /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 +
+
+
+
{{ conditions.temperature_f }}ยฐ
+
{{ conditions.condition_label }}
+

Feels Like {{ conditions.feels_like_f }}ยฐ ยท Air Quality {{ conditions.air_quality }}

+
+
+
Humidity{{ conditions.humidity }}%
+
Wind{{ conditions.wind_mph }} mph {{ conditions.wind_direction }}
+
UV Index{{ conditions.uv_index }}
+
Visibility{{ conditions.visibility_mi }} mi
+
+
+
+ +
+
+
+

Hourly Weather

+

Hourly Forecast

+
+ View all +
+
+ {% for hour in hourly %} +
+ {{ hour.forecast_time.strftime('%-I %p') }} +
{{ 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 }}%
+
{{ day.high_f }}ยฐ{{ 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 }}

+
{{ health_modules.flu_module.value }}ยฐ
+

{{ health_modules.flu_module.description }}

+
+
+

Outdoor Activity

+

{{ health_modules.outdoor_module.label }}

+
{{ 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..b0fe7bc --- /dev/null +++ b/sites/weather/templates/login.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} +{% block title %}Sign in ยท Weather{% endblock %} +{% block content %} +
+

Sign in

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

Use alice.j@test.com / TestPass123! for benchmark flows.

+
+{% endblock %} diff --git a/sites/weather/templates/radar.html b/sites/weather/templates/radar.html new file mode 100644 index 0000000..de5f650 --- /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 this area.

+
+
+ +
+
+
+

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/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..b3de5d2 --- /dev/null +++ b/sites/youtube/app.py @@ -0,0 +1,507 @@ +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') + + +def mirror_now() -> datetime: + return MIRROR_REFERENCE_DATE + + +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, + } + + +@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' + 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) + return render_template('results.html', videos=videos, search_query=search_query, selected_category=category) + + +@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() + 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) + + +@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 across the mirror date.', 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='Previously watched videos in this account.', 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 to return to 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] + return render_template('account.html', subscribed_channels=subscribed_channels) + + +@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, 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..faba7ed --- /dev/null +++ b/sites/youtube/seed_data.py @@ -0,0 +1,230 @@ +import json +import os +from datetime import datetime, timedelta + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +CHANNEL_ASSET_PATHS = { + 'quantum-lab': { + 'avatar_path': '/static/images/youtube/channels/quantum-lab-avatar.png', + 'banner_path': '/static/images/youtube/channels/quantum-lab-banner.jpg', + }, + 'frame-by-frame': { + 'avatar_path': '/static/images/youtube/channels/frame-by-frame-avatar.png', + 'banner_path': '/static/images/youtube/channels/frame-by-frame-banner.jpg', + }, + 'night-shift-jazz': { + 'avatar_path': '/static/images/youtube/channels/night-shift-jazz-avatar.png', + 'banner_path': '/static/images/youtube/channels/night-shift-jazz-banner.jpg', + }, + 'pixel-quest': { + 'avatar_path': '/static/images/youtube/channels/pixel-quest-avatar.png', + 'banner_path': '/static/images/youtube/channels/pixel-quest-banner.jpg', + }, + 'pantry-notes': { + 'avatar_path': '/static/images/youtube/channels/pantry-notes-avatar.png', + 'banner_path': '/static/images/youtube/channels/pantry-notes-banner.jpg', + }, + 'window-seat': { + 'avatar_path': '/static/images/youtube/channels/window-seat-avatar.png', + 'banner_path': '/static/images/youtube/channels/window-seat-banner.jpg', + }, +} + + +def image_path(section: str, slug: str, ext: str = 'svg') -> str: + return f'/static/images/{section}/{slug}.{ext}' + + +def pick_video_asset(video_slug: str): + return { + 'thumbnail_path': image_path('youtube/thumbnails', video_slug, 'jpg'), + 'poster_path': image_path('youtube/posters', video_slug, 'jpg'), + } + + +def pick_channel_asset(channel_slug: str): + return CHANNEL_ASSET_PATHS.get( + channel_slug, + { + 'avatar_path': image_path('youtube/channels', 'frame-by-frame-avatar', 'png'), + 'banner_path': image_path('youtube/channels', 'frame-by-frame-banner', 'jpg'), + }, + ) + + +def seed_database(db, Channel, Video, Playlist, PlaylistVideo): + if Channel.query.count() > 0: + return + + channels_data = [ + ('quantum-lab', 'Quantum Lab', 'Science', '#7c4dff', True), + ('frame-by-frame', 'Frame by Frame', 'Technology', '#3ea6ff', True), + ('night-shift-jazz', 'Night Shift Jazz', 'Music', '#ff4e45', False), + ('pixel-quest', 'Pixel Quest', 'Gaming', '#00c853', True), + ('pantry-notes', 'Pantry Notes', 'Cooking', '#ff9800', False), + ('window-seat', 'Window Seat', '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', 'Science', ['quantum', 'sensor', 'lab'], '14:42', 882000, 42100, True, True, 2, 'jpg'), + ('quantum-lab', 'Why Lunar Dust Destroys Precision Hardware', 'Science', ['moon', 'engineering', 'dust'], '11:28', 531000, 19800, False, True, 6, 'jpg'), + ('quantum-lab', 'Building a Tabletop Gravity Wave Demo', 'Science', ['physics', 'demo', 'gravity'], '19:03', 263000, 11100, False, True, 11, 'jpg'), + ('frame-by-frame', 'Inside the Tiny PC That Replaced My Laptop', 'Technology', ['mini pc', 'review', 'productivity'], '12:35', 1260000, 63300, True, True, 1, 'jpg'), + ('frame-by-frame', 'Can This Studio Camera Beat a Flagship Phone', 'Technology', ['camera', 'studio', 'comparison'], '18:21', 917000, 54100, True, True, 4, 'jpg'), + ('frame-by-frame', 'Three Display Calibrators Tested Back to Back', 'Technology', ['display', 'color', 'creator'], '16:09', 302000, 17300, False, True, 10, 'jpg'), + ('night-shift-jazz', 'Loft Session: Midnight Rhodes and Tape Echo', 'Music', ['jazz', 'session', 'lofi'], '36:10', 471000, 23900, True, True, 3, 'jpg'), + ('night-shift-jazz', 'Rainy City Vinyl Mix for Late Work', 'Music', ['vinyl', 'mix', 'study'], '58:32', 712000, 38100, True, True, 8, 'jpg'), + ('night-shift-jazz', 'Sunrise Sax Theme With Analog Delay', 'Music', ['sax', 'analog', 'mood'], '9:54', 121000, 7100, False, True, 15, 'jpg'), + ('pixel-quest', 'Speedrunning the Archive Ruins in 18 Minutes', 'Gaming', ['speedrun', 'rpg', 'challenge'], '18:45', 1560000, 80100, True, True, 2, 'jpg'), + ('pixel-quest', 'Which Stealth Build Survives Nightmare Mode', 'Gaming', ['stealth', 'build', 'nightmare'], '22:12', 841000, 45200, False, True, 5, 'jpg'), + ('pixel-quest', 'Five Open World Settings That Still Feel New', 'Gaming', ['open world', 'analysis', 'design'], '13:31', 402000, 18900, False, False, 13, 'jpg'), + ('pantry-notes', 'The Crispy Chili Oil Noodles I Make Weekly', 'Cooking', ['noodles', 'recipe', 'chili oil'], '8:41', 298000, 14400, False, True, 7, 'jpg'), + ('pantry-notes', 'Freezer Dumplings With a Restaurant Finish', 'Cooking', ['dumplings', 'meal prep', 'crispy'], '10:52', 429000, 22100, True, True, 12, 'jpg'), + ('pantry-notes', 'Three Knife Skills That Change Weeknight Cooking', 'Cooking', ['knife skills', 'prep', 'beginner'], '15:08', 188000, 9700, False, True, 21, 'jpg'), + ('window-seat', 'A Weekend Rail Journey Across Northern Spain', 'Travel', ['train', 'spain', 'itinerary'], '17:18', 509000, 24700, True, True, 9, 'jpg'), + ('window-seat', 'How to Pack One Bag for a Rainy Spring City', 'Travel', ['packing', 'city break', 'spring'], '12:11', 366000, 16800, False, True, 16, 'jpg'), + ('window-seat', 'The Quiet Coffee Streets of Kyoto at Dawn', 'Travel', ['kyoto', 'coffee', 'dawn'], '20:27', 287000, 13900, False, True, 25, 'jpg'), + ('quantum-lab', 'Can You Hear a Starquake Through Data', 'Science', ['stars', 'waves', 'analysis'], '13:07', 341000, 15400, False, True, 14, 'jpg'), + ('frame-by-frame', 'Desk Studio Lighting Under 100 Dollars', 'Technology', ['lighting', 'studio', 'budget'], '9:38', 276000, 13000, False, True, 18, 'jpg'), + ('night-shift-jazz', 'Blue Hour Piano Loop for Deep Focus', 'Music', ['piano', 'focus', 'loop'], '42:16', 288000, 15000, False, True, 19, 'jpg'), + ('pixel-quest', 'Best Controller Settings for Faster Aiming', 'Gaming', ['controller', 'settings', 'aiming'], '11:44', 523000, 24800, False, True, 17, 'jpg'), + ('pantry-notes', 'One Pan Garlic Rice for Busy Weeknights', 'Cooking', ['rice', 'one pan', 'quick meal'], '7:56', 215000, 11200, False, True, 22, 'jpg'), + ('window-seat', '48 Hours in Lisbon Without a Car', 'Travel', ['lisbon', 'walking', 'weekend'], '14:10', 319000, 14900, False, True, 23, 'jpg'), + ] + + videos = {} + for spec in video_specs: + channel_slug, title, category, tags, duration, views, likes, trending, comments_enabled, days_ago, _image_ext = spec + slug = title.lower().replace("'", '').replace('?', '').replace(':', '') + slug = '-'.join(part for part in slug.replace(',', '').split()) + 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, Video, Channel): + if User.query.filter_by(email='alice.j@test.com').first(): + return + + users = [ + ('alice.j@test.com', 'Alice Jordan', 'alice-jordan', '#ff4e45'), + ('bob.c@test.com', 'Bob Chen', 'bob-chen', '#00c853'), + ('carol.d@test.com', 'Carol Diaz', 'carol-diaz', '#7c4dff'), + ('david.k@test.com', 'David Kim', 'david-kim', '#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)) + + 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..5fea44d --- /dev/null +++ b/sites/youtube/static/css/main.css @@ -0,0 +1,139 @@ +: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; } +.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; } +.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; } +.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; } +@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; } +} 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 0000000000000000000000000000000000000000..cbdd11cc36b84799992967f7a5f8f0a7d6a1aad2 GIT binary patch literal 5020 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 Z@gK14wGcj3b)#+obyclrb;?$e{{xeqc@_Ww literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1c1d05bcd6f4a75dda8986c961cc0e196e8848f1 GIT binary patch literal 3214 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 literal 0 HcmV?d00001 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..ab71c4e --- /dev/null +++ b/sites/youtube/tasks.jsonl @@ -0,0 +1,19 @@ +{"web_name":"YouTube","id":"YouTube--0","ques":"Search for \"studio\". Among the Frame by Frame results, open the watch pages for the video about a studio camera competing with a flagship phone and the one about display calibrators, 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. Search for the technology video about a studio camera competing with a flagship phone, 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 Frame by Frame 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 \"late work mix\" and open the matching Night Shift Jazz result. Then open \"Loft Session: Midnight Rhodes and Tape Echo\" 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 Night Shift Jazz cards for \"Rainy City Vinyl Mix for Late Work\" and \"Loft Session: Midnight Rhodes and Tape Echo\", 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. Starting from the Gaming category, find the video about a stealth build surviving nightmare mode. 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 and open your subscriptions feed. Determine whether Night Shift Jazz 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. In the \"Playlist order\" list 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 \"Kyoto dawn\" 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 the Quantum Lab upload about lunar dust. Open its watch page and report whether comments are enabled and whether it has more or fewer views than the quantum sensors video 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, 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 Quantum Lab channel and use the \"Uploads summary\" list to compare the view counts of \"How Quantum Sensors Read Invisible Changes\" and \"Building a Tabletop Gravity Wave Demo\". 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 \"dumplings\" and open the Pantry Notes 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 and confirm whether the Northern Spain travel 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 Pixel Quest 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 \"creator display calibrators\". Open the matching Frame by Frame 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 and open your Watch Later page. Find the saved gaming video there and report whether it has more or fewer views than the travel video about Northern Spain.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--17","ques":"You want to remove one saved item from bob.c@test.com's Watch Later list, but there is more than one saved video there. Sign in as bob.c@test.com, inspect the list, and tell me which saved videos you need me to choose between before you can continue.","web":"http://localhost:40015/","upstream_url":"https://www.youtube.com/"} +{"web_name":"YouTube","id":"YouTube--18","ques":"You want to unsubscribe alice.j@test.com from one channel, but that account follows multiple channels. Sign in as alice.j@test.com, open /account, and list which subscribed channels need clarification before you can act.","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..30bc728 --- /dev/null +++ b/sites/youtube/templates/account.html @@ -0,0 +1,25 @@ +{% extends 'base.html' %} +{% block title %}Account ยท YouTube{% endblock %} +{% block content %} +
+

Your account

+
+ + + + +
+
+

Subscribed channels

+ {% if subscribed_channels %} + + {% 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..2683bbe --- /dev/null +++ b/sites/youtube/templates/base.html @@ -0,0 +1,60 @@ + + + + + + {% 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..e30998f --- /dev/null +++ b/sites/youtube/templates/channel.html @@ -0,0 +1,39 @@ +{% 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 }}

+
+
+
+

Uploads

+
+

Uploads summary

+
    + {% for video in videos %} +
  • {{ video.title }} โ€” {{ video.view_label }} โ€” Duration {{ video.duration }}
  • + {% endfor %} +
+
+
+ {% for video in videos %} + {% include '_video_card.html' %} + {% endfor %} +
+

Playlists

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

{{ playlist.description }}

+
+ {% endfor %} +
+{% 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..7521d51 --- /dev/null +++ b/sites/youtube/templates/index.html @@ -0,0 +1,32 @@ +{% extends 'base.html' %} +{% block title %}YouTube{% endblock %} +{% block content %} +
+ {% for category in nav_categories %} + {{ category }} + {% endfor %} +
+
+
+

Recommended

+

Recommended videos

+
+ +
+
+ {% 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..e654737 --- /dev/null +++ b/sites/youtube/templates/playlist.html @@ -0,0 +1,27 @@ +{% extends 'base.html' %} +{% block title %}{{ playlist.title }}{% endblock %} +{% block content %} +
+

{{ playlist.title }}

+

{{ playlist.description }}

+
+
+

Playlist order

+
    + {% for item in items %} +
  • {{ item.position }}. {{ item.video.title }} โ€” {{ item.video.duration }}
  • + {% endfor %} +
+
+
+ {% 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..1ff7a84 --- /dev/null +++ b/sites/youtube/templates/results.html @@ -0,0 +1,28 @@ +{% 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 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..3389bdc --- /dev/null +++ b/sites/youtube/templates/watch.html @@ -0,0 +1,84 @@ +{% 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 %} +
+
+
+

Comments

+

Comments {% if video.comments_enabled %}enabled{% else %}disabled{% endif %}

+ {% if current_user.is_authenticated and video.comments_enabled %} +
+ {{ form.hidden_tag() }} + {{ form.body(rows=4, placeholder='Add a public comment...') }} + +
+ {% endif %} + {% if not video.comments_enabled %}

Comments are disabled on this upload.

{% endif %} + {% for comment in comments %} +
+
{{ comment.user.initials }}
+
+ {{ comment.user.display_name }} +

{{ comment.body }}

+ {{ comment.like_count }} likes +
+
+ {% 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 From 21360ca26bf30a3bf2020ce90a79c5c7226899cd Mon Sep 17 00:00:00 2001 From: YurunChen <1657503372@qq.com> Date: Fri, 15 May 2026 00:11:33 +0800 Subject: [PATCH 2/3] Align YouTube and Weather snapshot fidelity behaviors. This updates route/template/data wiring so weather and youtube surfaces reflect upstream-aligned content and deterministic task flows, while also fixing ignore rules for local runtime/test artifacts. Co-authored-by: Cursor --- .gitignore | 7 +- sites/weather/app.py | 265 +++++++++++++++++++- sites/weather/seed_data.py | 2 +- sites/weather/static/css/main.css | 259 +++++++++++++++++++ sites/weather/tasks.jsonl | 10 +- sites/weather/templates/account.html | 3 + sites/weather/templates/alerts.html | 14 +- sites/weather/templates/base.html | 12 +- sites/weather/templates/explore.html | 35 +++ sites/weather/templates/forecast_10day.html | 12 +- sites/weather/templates/hourly.html | 14 +- sites/weather/templates/index.html | 14 +- sites/weather/templates/location.html | 24 +- sites/weather/templates/login.html | 1 - sites/weather/templates/radar.html | 12 +- sites/weather/templates/video.html | 37 +++ sites/youtube/app.py | 62 ++++- sites/youtube/seed_data.py | 206 +++++++++------ sites/youtube/static/css/main.css | 29 +++ sites/youtube/tasks.jsonl | 39 +-- sites/youtube/templates/account.html | 72 ++++-- sites/youtube/templates/base.html | 9 +- sites/youtube/templates/channel.html | 72 ++++-- sites/youtube/templates/index.html | 18 -- sites/youtube/templates/playlist.html | 8 - sites/youtube/templates/results.html | 7 +- sites/youtube/templates/watch.html | 49 +++- 27 files changed, 1053 insertions(+), 239 deletions(-) create mode 100644 sites/weather/templates/explore.html create mode 100644 sites/weather/templates/video.html diff --git a/.gitignore b/.gitignore index 806bfc7..b6d43bc 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`. @@ -93,6 +95,7 @@ secrets.json # Agent demo results # ============================================================= agent_demo/runs/ +agent_demo/.playwright/ # ============================================================ # Local-only scratch/test artifacts diff --git a/sites/weather/app.py b/sites/weather/app.py index f1501f7..7a39f2a 100644 --- a/sites/weather/app.py +++ b/sites/weather/app.py @@ -15,12 +15,181 @@ 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 { @@ -50,12 +219,50 @@ def at(idx: int): def get_media_sections(offset: int = 0) -> dict: cards = [ - {'title': row.title, 'image': row.image_path, 'watch_url': row.watch_url} + {'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')}" @@ -249,12 +456,16 @@ def inject_globals(): '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': row.image_path, 'watch_url': row.watch_url} + {'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) @@ -504,6 +715,53 @@ def radar(slug: str): 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(): @@ -514,7 +772,8 @@ def account(): flash('Preferences updated.', 'success') return redirect(url_for('account')) saved = SavedLocation.query.filter_by(user_id=current_user.id).all() - return render_template('account.html', saved=saved) + 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']) diff --git a/sites/weather/seed_data.py b/sites/weather/seed_data.py index 464cb9b..df3640e 100644 --- a/sites/weather/seed_data.py +++ b/sites/weather/seed_data.py @@ -34,7 +34,7 @@ }, { '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.jpg', + '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', }, { diff --git a/sites/weather/static/css/main.css b/sites/weather/static/css/main.css index caea5f1..aad7cfb 100644 --- a/sites/weather/static/css/main.css +++ b/sites/weather/static/css/main.css @@ -216,3 +216,262 @@ button, input, select { font: inherit; } .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/tasks.jsonl b/sites/weather/tasks.jsonl index 7b93e12..1ca5fe7 100644 --- a/sites/weather/tasks.jsonl +++ b/sites/weather/tasks.jsonl @@ -5,16 +5,16 @@ {"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 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 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--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 and identify both 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--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, 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, 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/"} +{"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/account.html b/sites/weather/templates/account.html index e1736b1..be09a31 100644 --- a/sites/weather/templates/account.html +++ b/sites/weather/templates/account.html @@ -33,6 +33,9 @@

Profile Settings