diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 6c60d83e..46c70ebe 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -29,6 +29,7 @@ backend/ ├── leaderboard/ # Leaderboard and rankings ├── users/ # User management and auth ├── partners/ # Ecosystem partners directory +├── gen_tv/ # Gen TV livestream index ├── utils/ # Shared utilities └── backend/ # Django project settings ``` @@ -129,6 +130,17 @@ backend/ - **Migrations**: `partners/migrations/0001_initial.py` creates the model and seeds the 22 founding partners from a `RunPython` step. - **Admin**: `partners/admin.py` - list_editable on `display_order`, `is_active`; slug prepopulated from name. +### Gen TV +- **Models**: `gen_tv/models.py` + - Stream - Livestream entry with `title`, `slug`, `description`, `url`, `image_url`, `starts_at` (required), `ends_at` (required), `category` (`internal` / `community`), `is_active`. `status` is a derived `@property` computed from `starts_at`/`ends_at` (no DB column). +- **Serializers**: `gen_tv/serializers.py` + - LightStreamSerializer - Minimal fields for list views (status comes through as a read-only string) + - StreamSerializer - Full fields for detail +- **Views**: `gen_tv/views.py` + - `/api/v1/gen-tv/streams/` - Public read-only list with `category` filter; pagination disabled (small dataset) + - `/api/v1/gen-tv/streams/{slug}/` - Public read-only detail by slug +- **Admin**: `gen_tv/admin.py` - status surfaces as a read-only `computed_status` column; date_hierarchy on `starts_at`; slug prepopulated from title. + ### Database & Migrations - **Migrations**: `{app}/migrations/` - **Database**: SQLite by default, configured in settings.py @@ -248,6 +260,10 @@ GET /api/v1/ai-review/templates/ # Partners (Ecosystem Partners) GET /api/v1/partners/ (public, list active partners) GET /api/v1/partners/{slug}/ (public, partner detail) + +# Gen TV +GET /api/v1/gen-tv/streams/ (public, supports ?category= filter) +GET /api/v1/gen-tv/streams/{slug}/ (public, stream detail) ``` ## Environment Variables diff --git a/backend/api/urls.py b/backend/api/urls.py index 9eaa35bc..6cbe4d6b 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -4,6 +4,7 @@ from contributions.views import ContributionTypeViewSet, ContributionViewSet, EvidenceViewSet, SubmittedContributionViewSet, StewardSubmissionViewSet, MissionViewSet, StartupRequestViewSet, FeaturedContentViewSet, AlertViewSet from leaderboard.views import GlobalLeaderboardMultiplierViewSet, LeaderboardViewSet from partners.views import PartnerViewSet +from gen_tv.views import StreamViewSet from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView from .metrics_views import ActiveValidatorsView, ContributionTypesStatsView, ParticipantsGrowthView, TestnetMetricsView @@ -22,6 +23,7 @@ router.register(r'featured', FeaturedContentViewSet, basename='featured') router.register(r'alerts', AlertViewSet, basename='alert') router.register(r'partners', PartnerViewSet, basename='partner') +router.register(r'gen-tv/streams', StreamViewSet, basename='stream') # The API URLs are now determined automatically by the router urlpatterns = [ diff --git a/backend/gen_tv/__init__.py b/backend/gen_tv/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/gen_tv/admin.py b/backend/gen_tv/admin.py new file mode 100644 index 00000000..3a330bfe --- /dev/null +++ b/backend/gen_tv/admin.py @@ -0,0 +1,39 @@ +from django.contrib import admin + +from .models import Stream + + +@admin.register(Stream) +class StreamAdmin(admin.ModelAdmin): + list_display = ( + 'title', + 'category', + 'computed_status', + 'starts_at', + 'ends_at', + 'is_active', + ) + list_editable = ('is_active',) + list_filter = ('category', 'is_active') + search_fields = ('title', 'description') + prepopulated_fields = {'slug': ('title',)} + date_hierarchy = 'starts_at' + readonly_fields = ('created_at', 'updated_at') + fieldsets = ( + (None, { + 'fields': ('title', 'slug', 'description', 'is_active'), + }), + ('Channel & Schedule', { + 'fields': ('category', 'starts_at', 'ends_at'), + }), + ('Media', { + 'fields': ('url', 'image_url'), + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + }), + ) + + @admin.display(description='Status') + def computed_status(self, obj): + return obj.status diff --git a/backend/gen_tv/apps.py b/backend/gen_tv/apps.py new file mode 100644 index 00000000..ece8bb89 --- /dev/null +++ b/backend/gen_tv/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class GenTvConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'gen_tv' + verbose_name = 'Gen TV' diff --git a/backend/gen_tv/migrations/0001_initial.py b/backend/gen_tv/migrations/0001_initial.py new file mode 100644 index 00000000..67adf5bd --- /dev/null +++ b/backend/gen_tv/migrations/0001_initial.py @@ -0,0 +1,32 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Stream', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=300)), + ('slug', models.SlugField(max_length=300, unique=True)), + ('description', models.TextField(blank=True)), + ('url', models.URLField(help_text='Source URL (X / Twitter, YouTube, etc.).', max_length=500)), + ('image_url', models.URLField(blank=True, help_text='Thumbnail / cover image URL.', max_length=500)), + ('starts_at', models.DateTimeField(help_text='Scheduled start time (used for sorting and status).')), + ('ends_at', models.DateTimeField(help_text='Scheduled end time (used to compute status and the duration badge).')), + ('category', models.CharField(choices=[('internal', 'GenLayer Team'), ('community', 'Community')], max_length=20)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['-starts_at'], + }, + ), + ] diff --git a/backend/gen_tv/migrations/0002_seed_streams.py b/backend/gen_tv/migrations/0002_seed_streams.py new file mode 100644 index 00000000..2ad1d9f9 --- /dev/null +++ b/backend/gen_tv/migrations/0002_seed_streams.py @@ -0,0 +1,318 @@ +from datetime import datetime, timedelta, timezone + +from django.db import migrations +from django.utils.text import slugify + + +CLOUDINARY_BASE = "https://res.cloudinary.com/dfqmoeawa/image/upload/gen_tv" +DEFAULT_BANNER = f"{CLOUDINARY_BASE}/default-banner.png" +DURATION = timedelta(minutes=30) + + +def _at(year: int, month: int, day: int, hour: int, minute: int = 0) -> datetime: + return datetime(year, month, day, hour, minute, tzinfo=timezone.utc) + + +# Each tuple: (slug, title, starts_at, source_url, description, has_own_banner) +# `has_own_banner=True` -> uses gen_tv/.png; False -> default banner. +STREAMS: list[tuple[str, str, datetime, str, str, bool]] = [ + ( + "barcelona-openclaw-meetup", + "Barcelona Openclaw Meetup Livestream", + _at(2026, 2, 10, 18, 0), + "https://x.com/i/broadcasts/1mnxeNgXbbqKX", + "Discussion of the emerging OpenClaw technology.\n\n" + "Speakers: @MorpheusAIs's speaker, @driudor, @akka_io_'s speaker, " + "@joaquinbressan, @cognocracy, @kstellana", + False, + ), + ( + "presenting-hackathon", + "Presenting Hackathon", + _at(2026, 3, 17, 16, 0), + "https://x.com/i/broadcasts/1NGaraDelYdJj", + "Ivan introduced the Hackathon, outlining its timeline, main features, " + "award categories, and focus areas.\n\n" + "Speakers: @raskovsky", + True, + ), + ( + "from-zero-to-genlayer", + "From Zero to GenLayer", + _at(2026, 3, 19, 11, 0), + "https://x.com/i/broadcasts/1jxXgeaLLgRJZ", + "A vibecoding session organized specifically for the Hackathon. The session " + "covered the main aspects of development on GenLayer, including consensus " + "specifics and other technical details. Ivan shared a developer presentation " + "with documentation and useful links. The host also discussed project ideas " + "that could be built on GenLayer during the Hackathon.\n\n" + "Speakers: @raskovsky", + True, + ), + ( + "bradbury-builders-hackathon-launch", + "Bradbury Builders Hackathon Launch", + _at(2026, 3, 20, 13, 30), + "https://x.com/i/broadcasts/1nxnRYADmgoxO", + "The sponsors of the Hackathon, concurrently our validators, were introduced. " + "Ivan also once again outlined the Hackathon conditions and emphasized the " + "advantages of developing applications on GenLayer, including lifetime fees. " + "To provide more context, existing solutions such as argue.fun, mergeproof.com, " + "and others were showcased.\n\n" + "Speakers: @raskovsky, Dasha from @stakeme_pro, Anton from @CroutonDigital, " + "Patrick from @pathrock2, Albury from @chutes_ai", + True, + ), + ( + "vibecoding-bradbury-gym", + "GenLayer Vibecoding Series: Bradbury Gym", + _at(2026, 4, 1, 16, 30), + "https://x.com/i/broadcasts/1aKbdbyZdjqJX", + "The session covered the main features and advantages of the Bradbury testnet, " + "followed by a vibecoding demonstration of a benchmark running on GenLayer.\n\n" + "Speakers: @raskovsky, @cognocracy", + True, + ), + ( + "bradbury-hackathon-winners", + "Bradbury Hackathon Winners Announcement", + _at(2026, 4, 14, 14, 0), + "https://x.com/i/broadcasts/1PKqrElvYVmGb", + "Bradbury Hackathon Winners Review: Ivan presented a summary of the Hackathon " + "results and showcased each winning project.\n\n" + "Speakers: @raskovsky", + True, + ), + ( + "bradbury-hackathon-demo-day", + "Bradbury Hackathon Demo Day", + _at(2026, 4, 17, 15, 0), + "https://x.com/i/broadcasts/1AJEmOqRBoZJL", + "Discussion with the teams behind BuildersClaw, AutoBounty, and TreasuryPilot — " + "winners of the Bradbury Hackathon — including their backgrounds and demos of " + "their applications.\n\n" + "Speakers: @raskovsky, team @buildersclaw, @ArtuGrande (AutoBounty), " + "@sandraupgrade (TreasuryPilot)", + True, + ), + ( + "nov-2025-hackathon-submissions", + "GenLayer November 2025 Hackathon — Live Submissions Review", + _at(2025, 11, 14, 15, 15), + "https://x.com/i/broadcasts/1ZkJzZygWndJv", + "Live overview of GenLayer Hackathon applications.\n\n" + "Speakers: @raskovsky", + True, + ), + ( + "vibecoding-ep1", + "GenLayer Vibecoding Series Episode 1: GenLayer Introduction & Basics", + _at(2025, 12, 23, 14, 0), + "https://x.com/i/broadcasts/1OdKrOvXjrYGX", + "Vibecoding Series Episode 1. Ivan showcased GenLayer Studio and explained " + "the structure of an intelligent contract.\n\n" + "Speakers: @raskovsky", + True, + ), + ( + "vibecoding-ep2", + "GenLayer Vibecoding Series Episode 2: Our First Intelligent Contract", + _at(2025, 12, 30, 14, 0), + "https://x.com/i/broadcasts/1eaKbjQdYgrKX", + "Vibecoding Series Episode 2. Setting up the Claude terminal, creating the " + "first Intelligent Contract, testing it in Studio, and fixing bugs.\n\n" + "Speakers: @raskovsky", + True, + ), + ( + "vibecoding-ep3", + "GenLayer Vibecoding Series Episode 3: From an Intelligent Contract to a DApp", + _at(2026, 1, 7, 14, 0), + "https://x.com/i/broadcasts/1ypJdqwnwPaxW", + "Vibecoding Series Episode 3. Building a DApp by combining a contract and a " + "frontend using the Claude terminal.\n\n" + "Speakers: @raskovsky", + True, + ), + ( + "vibecoding-ep4", + "GenLayer Vibecoding Series Episode 4: Iterating, Polishing and Deploying", + _at(2026, 1, 13, 14, 0), + "https://x.com/i/broadcasts/1ypKdqgAWkpGW", + "Vibecoding Series Episode 4. Continuing DApp moderation: editing and fixing " + "bugs with the Claude terminal and GenLayer Studio.\n\n" + "Speakers: @raskovsky", + True, + ), + ( + "gentalks-ep1", + "GenTalks Episode 1", + _at(2026, 1, 21, 13, 30), + "https://x.com/GenLayer/status/2013747638517047635", + "", + True, + ), + ( + "gentalks-ep2", + "GenTalks Episode 2", + _at(2026, 1, 28, 13, 30), + "https://x.com/GenLayer/status/2016195968031396258", + "", + True, + ), + ( + "gentalks-ep3", + "GenTalks Episode 3", + _at(2026, 2, 5, 15, 30), + "https://x.com/i/broadcasts/1ypKdqpXYLrGW", + "Discussion of the current market landscape, emerging technologies such as " + "OpenClaw, ClawBot, Moltbook, RentAHuman.ai, ClawTasks, Argue.fun, and ClawHub, " + "and GenLayer's role. An IRL meetup in Ukraine was also discussed.\n\n" + "Speakers: @raskovsky, @driudor", + True, + ), + ( + "gentalks-ep4", + "GenTalks Episode 4", + _at(2026, 2, 11, 14, 30), + "https://x.com/i/broadcasts/1MYxNlwjdyLGw", + "Speakers: @raskovsky, @driudor", + True, + ), + ( + "gentalks-ep5", + "GenTalks Episode 5", + _at(2026, 2, 25, 14, 30), + "https://x.com/i/broadcasts/1yxBeMebmnYJN", + "Introduction to Internet Court and Rally updates, as well as the Community " + "section on the Portal, and the automatic submission review system. The " + "discussion also included Argue.fun features for autonomous agents and " + "introductions to Mergeproof.com and Molly.fun.\n\n" + "Speakers: @raskovsky, @driudor", + True, + ), + ( + "gentalks-ep6", + "GenTalks Episode 6", + _at(2026, 3, 4, 14, 30), + "https://x.com/i/broadcasts/1DxleEVZyndKL", + "Discussion of Rally.fun and Argue.fun, and the future role of autonomous " + "agents in shaping the internet. Presentation of Botcha.xyz as a CAPTCHA " + "solution designed for agents.\n\n" + "Speakers: @raskovsky, @joaquinbressan", + True, + ), + ( + "gentalks-ep7", + "GenTalks Episode 7", + _at(2026, 3, 11, 14, 30), + "https://x.com/i/broadcasts/1rxmqoVaVWDxy", + "Edgars spoke about what makes Bradbury stand out today, shared insights on " + "validators, and discussed Argue.fun as a modern approach to dispute " + "resolution.\n\n" + "Speakers: @raskovsky, @driudor, @EdgarsNemse", + True, + ), + ( + "gentalks-ep8", + "GenTalks Episode 8", + _at(2026, 3, 18, 14, 30), + "https://x.com/i/broadcasts/1wxWjaZPpYnJQ", + "First impressions from the Bradbury launch. Albert talked about his " + "experience building on GenLayer and broke down the Developer Fee tokenomics. " + "Review of skills.genlayer.com.\n\n" + "Speakers: @raskovsky, @driudor, @kstellana", + True, + ), + ( + "gentalks-ep9", + "GenTalks Episode 9", + _at(2026, 3, 25, 14, 30), + "https://x.com/i/broadcasts/1qxvvkzNrbRxB", + "A look at the latest stats and some Hackathon projects, followed by a " + "discussion about the GenLayer Portal.\n\n" + "Speakers: @raskovsky, @driudor", + True, + ), + ( + "gentalks-ep10", + "GenTalks Episode 10", + _at(2026, 4, 1, 14, 30), + "https://x.com/i/broadcasts/1RKZzjyvzAXKB", + "David and Ivan discuss the current Hackathon results and the support " + "provided to participating teams. They also review internetcourt.org, share " + "thoughts on GenTalks, cover the latest Bradbury testnet news, and go over " + "GenLayer Portal submission statistics.\n\n" + "Speakers: @raskovsky, @driudor", + True, + ), + ( + "gentalks-ep11", + "GenTalks Episode 11", + _at(2026, 4, 8, 14, 30), + "https://x.com/i/broadcasts/1aJbdbBQLqoKX", + "Overview of the GenLayer Hackathon submissions.\n\n" + "Speakers: @raskovsky, @driudor", + True, + ), + ( + "gentalks-ep12", + "GenTalks Episode 12", + _at(2026, 4, 15, 14, 30), + "https://x.com/i/broadcasts/1rGmqolwYdqGy", + "GenLayer Hackathon results and reviews of several projects. Answers to " + "community questions. Introducing pmkit.courtofinternet.com, a tool for " + "creating prediction markets.\n\n" + "Speakers: @raskovsky, @driudor", + True, + ), + ( + "gentalks-ep13", + "GenTalks Episode 13", + _at(2026, 4, 22, 14, 30), + "https://x.com/i/broadcasts/1vJpPrpRaEQJE", + "Announcement of an IRL event in Argentina hosted by Ivan. Discussions about " + "builder meetups and plans from the GenLayer Foundation, including validator " + "onboarding and more.\n\n" + "Speakers: @raskovsky, @driudor", + True, + ), +] + + +def _image_url(slug: str, has_own_banner: bool) -> str: + return f"{CLOUDINARY_BASE}/{slug}.png" if has_own_banner else DEFAULT_BANNER + + +def seed_streams(apps, schema_editor): + Stream = apps.get_model("gen_tv", "Stream") + for slug, title, starts_at, url, description, has_own_banner in STREAMS: + Stream.objects.update_or_create( + slug=slug, + defaults={ + "title": title, + "description": description, + "url": url, + "image_url": _image_url(slug, has_own_banner), + "starts_at": starts_at, + "ends_at": starts_at + DURATION, + "category": "internal", + "is_active": True, + }, + ) + + +def unseed_streams(apps, schema_editor): + Stream = apps.get_model("gen_tv", "Stream") + Stream.objects.filter(slug__in=[s[0] for s in STREAMS]).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("gen_tv", "0001_initial"), + ] + + operations = [ + migrations.RunPython(seed_streams, unseed_streams), + ] diff --git a/backend/gen_tv/migrations/__init__.py b/backend/gen_tv/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/gen_tv/models.py b/backend/gen_tv/models.py new file mode 100644 index 00000000..f7c18812 --- /dev/null +++ b/backend/gen_tv/models.py @@ -0,0 +1,53 @@ +from django.db import models +from django.utils import timezone + +from utils.models import BaseModel + + +class Stream(BaseModel): + """A livestream entry shown on Gen TV (mostly X / Twitter, for now).""" + + class Category(models.TextChoices): + INTERNAL = 'internal', 'GenLayer Team' + COMMUNITY = 'community', 'Community' + + # Status is derived, not stored — see the `status` property below. + UPCOMING = 'upcoming' + LIVE = 'live' + PAST = 'past' + + title = models.CharField(max_length=300) + slug = models.SlugField(max_length=300, unique=True) + description = models.TextField(blank=True) + url = models.URLField( + max_length=500, + help_text="Source URL (X / Twitter, YouTube, etc.).", + ) + image_url = models.URLField( + max_length=500, + blank=True, + help_text="Thumbnail / cover image URL.", + ) + starts_at = models.DateTimeField( + help_text="Scheduled start time (used for sorting and status).", + ) + ends_at = models.DateTimeField( + help_text="Scheduled end time (used to compute status and the duration badge).", + ) + category = models.CharField(max_length=20, choices=Category.choices) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ['-starts_at'] + + def __str__(self): + return f"[{self.get_category_display()}] {self.title}" + + @property + def status(self): + now = timezone.now() + if now < self.starts_at: + return self.UPCOMING + if now < self.ends_at: + return self.LIVE + return self.PAST diff --git a/backend/gen_tv/serializers.py b/backend/gen_tv/serializers.py new file mode 100644 index 00000000..9644ba8e --- /dev/null +++ b/backend/gen_tv/serializers.py @@ -0,0 +1,49 @@ +from rest_framework import serializers + +from .models import Stream + + +class LightStreamSerializer(serializers.ModelSerializer): + """Minimal stream payload for list views.""" + + status = serializers.CharField(read_only=True) + + class Meta: + model = Stream + fields = [ + 'id', + 'title', + 'slug', + 'image_url', + 'url', + 'starts_at', + 'ends_at', + 'category', + 'status', + ] + read_only_fields = fields + + +class StreamSerializer(serializers.ModelSerializer): + """Full stream payload for detail views.""" + + status = serializers.CharField(read_only=True) + + class Meta: + model = Stream + fields = [ + 'id', + 'title', + 'slug', + 'description', + 'url', + 'image_url', + 'starts_at', + 'ends_at', + 'category', + 'status', + 'is_active', + 'created_at', + 'updated_at', + ] + read_only_fields = fields diff --git a/backend/gen_tv/tests/__init__.py b/backend/gen_tv/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/gen_tv/tests/test_streams.py b/backend/gen_tv/tests/test_streams.py new file mode 100644 index 00000000..1b8efdcc --- /dev/null +++ b/backend/gen_tv/tests/test_streams.py @@ -0,0 +1,64 @@ +from django.test import TestCase +from django.utils import timezone + +from gen_tv.models import Stream + + +class StreamAPITest(TestCase): + def setUp(self): + now = timezone.now() + Stream.objects.create( + title='Internal Live', + slug='internal-live', + url='https://x.com/genlayer/status/1', + scheduled_at=now, + category=Stream.Category.INTERNAL, + status=Stream.Status.LIVE, + ) + Stream.objects.create( + title='Internal Past', + slug='internal-past', + url='https://x.com/genlayer/status/2', + scheduled_at=now, + category=Stream.Category.INTERNAL, + status=Stream.Status.PAST, + ) + Stream.objects.create( + title='Community Upcoming', + slug='community-upcoming', + url='https://x.com/community/status/3', + scheduled_at=now, + category=Stream.Category.COMMUNITY, + status=Stream.Status.UPCOMING, + ) + Stream.objects.create( + title='Inactive', + slug='inactive', + url='https://x.com/genlayer/status/9', + scheduled_at=now, + category=Stream.Category.INTERNAL, + status=Stream.Status.LIVE, + is_active=False, + ) + + def test_list_excludes_inactive(self): + res = self.client.get('/api/v1/gen-tv/streams/') + self.assertEqual(res.status_code, 200) + results = res.json() + slugs = {s['slug'] for s in results} + self.assertSetEqual( + slugs, + {'internal-live', 'internal-past', 'community-upcoming'}, + ) + + def test_filter_by_category_and_status(self): + res = self.client.get('/api/v1/gen-tv/streams/?category=internal&status=live') + self.assertEqual(res.status_code, 200) + results = res.json() + self.assertEqual(len(results), 1) + self.assertEqual(results[0]['slug'], 'internal-live') + + def test_detail_uses_slug_lookup(self): + res = self.client.get('/api/v1/gen-tv/streams/community-upcoming/') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json()['slug'], 'community-upcoming') diff --git a/backend/gen_tv/views.py b/backend/gen_tv/views.py new file mode 100644 index 00000000..c8ec463a --- /dev/null +++ b/backend/gen_tv/views.py @@ -0,0 +1,23 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, permissions, viewsets + +from .models import Stream +from .serializers import LightStreamSerializer, StreamSerializer + + +class StreamViewSet(viewsets.ReadOnlyModelViewSet): + """Public read-only API for Gen TV streams.""" + + queryset = Stream.objects.filter(is_active=True) + permission_classes = [permissions.AllowAny] + filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] + filterset_fields = ['category'] + search_fields = ['title', 'description'] + ordering_fields = ['starts_at', 'created_at'] + lookup_field = 'slug' + pagination_class = None + + def get_serializer_class(self): + if self.action == 'list': + return LightStreamSerializer + return StreamSerializer diff --git a/backend/tally/settings.py b/backend/tally/settings.py index ab495a16..4dfc9913 100644 --- a/backend/tally/settings.py +++ b/backend/tally/settings.py @@ -78,6 +78,7 @@ def get_required_env(key): 'stewards', 'creators', 'partners', + 'gen_tv', 'social_connections', ] diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index d7332b6d..2dfbd7ec 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -375,6 +375,7 @@ const routes = { // Discover '/ecosystem-partners': EcosystemPartners, // Public directory of partners + validators + projects + '/gen-tv': GenTV, // Livestream index split by category section '*': NotFound } @@ -402,6 +403,7 @@ const routes = { - `journeyAPI` - Onboarding journeys (startBuilderJourney, startValidatorJourney, completeBuilderJourney, linkXAccount, linkDiscordAccount) - `creatorAPI` - Community/creator membership (joinAsCreator) - `partnersAPI` - Ecosystem partners directory (`list`, `get(slug)`) + - `genTvAPI` - Gen TV streams (`list`, `get(slug)`) ### Authentication (`src/lib/auth.js`) - **Auth Store**: Svelte store `authState` @@ -470,6 +472,12 @@ Reusable, data-driven display components that accept data via props. Used on Das - Partner cards put the logo on a black circle; validator/project cards use a soft-gradient initials fallback when no image is available - Click opens `item.href` (external opens in a new tab; validator profile links navigate in-app) +#### Gen TV Components (`src/components/portal/gen-tv/`) +- **`StreamCard.svelte`** - Card for a livestream with image, dark overlay, status badge, and title + - Props: `stream`, `variant='past'|'live'|'upcoming'` + - Computed `status` and `duration` come from `starts_at` / `ends_at` on the API payload + - Click opens `stream.url` in a new tab + #### How It Works / Landing Page Components (`src/components/portal/landing-page/`) - Used by the `/how-it-works` route (`HowItWorks.svelte`) - First-time users are redirected here after completing their profile via `ProfileCompletionGuard` diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 3f29b4b6..1d40e0f6 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -83,6 +83,7 @@ import TermsOfUse from './routes/TermsOfUse.svelte'; import PrivacyPolicy from './routes/PrivacyPolicy.svelte'; import EcosystemPartners from './routes/EcosystemPartners.svelte'; + import GenTV from './routes/GenTV.svelte'; import Referrals from './routes/Referrals.svelte'; import Community from './routes/Community.svelte'; import Hackathon from './routes/Hackathon.svelte'; @@ -162,6 +163,7 @@ // Ecosystem '/ecosystem-partners': EcosystemPartners, + '/gen-tv': GenTV, '*': NotFound }; diff --git a/frontend/src/components/Sidebar.svelte b/frontend/src/components/Sidebar.svelte index b7a0bbb8..c714d209 100644 --- a/frontend/src/components/Sidebar.svelte +++ b/frontend/src/components/Sidebar.svelte @@ -70,6 +70,7 @@ if (path.startsWith('/community')) return 'community'; if (path.startsWith('/stewards')) return 'steward'; if (path.startsWith('/ecosystem-partners')) return 'partners'; + if (path.startsWith('/gen-tv')) return 'gentv'; return null; } @@ -404,6 +405,24 @@ + +
+ +
+ @@ -760,6 +779,19 @@ Ecosystem Partners + + + diff --git a/frontend/src/components/portal/gen-tv/StreamCard.svelte b/frontend/src/components/portal/gen-tv/StreamCard.svelte new file mode 100644 index 00000000..647fe104 --- /dev/null +++ b/frontend/src/components/portal/gen-tv/StreamCard.svelte @@ -0,0 +1,110 @@ + + + +
+ {#if stream.image_url} + + {:else} +
+ {/if} + +
+ +
+
+ {#if isLive} + + + Live + + {:else if isUpcoming} + + Upcoming + + {:else} + + Ended + + {/if} + + {#if duration} + + {duration} + + {/if} +
+ +
+ +
+
+ +
+
+ {categoryLabel} + {#if host} + · + {host} + {/if} +
+

+ {stream.title} +

+ {#if stream.starts_at} +

+ {formatDateTime(stream.starts_at)} +

+ {/if} +
+
+
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index a5b00496..1f7da157 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -284,4 +284,10 @@ export const partnersAPI = { get: (slug) => api.get(`/partners/${slug}/`), }; +// Gen TV API +export const genTvAPI = { + list: (params) => api.get('/gen-tv/streams/', { params }), + get: (slug) => api.get(`/gen-tv/streams/${slug}/`), +}; + export default api; diff --git a/frontend/src/routes/GenTV.svelte b/frontend/src/routes/GenTV.svelte new file mode 100644 index 00000000..af3571bb --- /dev/null +++ b/frontend/src/routes/GenTV.svelte @@ -0,0 +1,146 @@ + + +
+
+

+ Gen TV +

+

+ Live and recorded streams from the GenLayer team and community. +

+
+ + {#if loading} +
+
+ {#each [1, 2, 3] as _} +
+ {/each} +
+
+ {#each [1, 2, 3, 4] as _} +
+ {/each} +
+
+ {:else if error} +
+ {error} +
+ {:else if groups.length === 0} +
+

No streams yet

+

+ Streams will appear here once they're scheduled. +

+
+ {:else} + {#each groups as group (group.id)} +
+

+ {group.label} +

+ + {#if group.live.length > 0} +
+

+ + Live now +

+
+ {#each group.live as stream (stream.id)} + + {/each} +
+
+ {/if} + + {#if group.upcoming.length > 0} +
+

+ Upcoming +

+
+ {#each group.upcoming as stream (stream.id)} + + {/each} +
+
+ {/if} + + {#if group.past.length > 0} +
+

+ Past streams +

+
+ {#each group.past as stream (stream.id)} + + {/each} +
+
+ {/if} +
+ {/each} + {/if} +
diff --git a/frontend/src/stores/category.js b/frontend/src/stores/category.js index 5cf408a2..d893b3f8 100644 --- a/frontend/src/stores/category.js +++ b/frontend/src/stores/category.js @@ -133,7 +133,7 @@ export function detectCategoryFromRoute(path) { return 'steward'; } else if (path.startsWith('/community')) { return 'community'; - } else if (path.startsWith('/ecosystem-partners')) { + } else if (path.startsWith('/ecosystem-partners') || path.startsWith('/gen-tv')) { return 'global'; } return 'global';