- Contribution reviews are currently delayed. All submissions are being recorded and will be reviewed shortly. Thank you for your patience and continued participation. -
-diff --git a/.claude/skills/implement-design.md b/.claude/skills/implement-design.md new file mode 100644 index 00000000..1170eaea --- /dev/null +++ b/.claude/skills/implement-design.md @@ -0,0 +1,252 @@ +--- +name: implement-design +description: Translates Figma designs into production-ready code with 1:1 visual fidelity. Use when implementing UI from Figma files, when user mentions "implement design", "generate code", "implement component", "build Figma design", provides Figma URLs, or asks to build components matching Figma specs. Requires Figma MCP server connection. +metadata: + mcp-server: figma, figma-desktop +--- + +# Implement Design + +## Overview + +This skill provides a structured workflow for translating Figma designs into production-ready code with pixel-perfect accuracy. It ensures consistent integration with the Figma MCP server, proper use of design tokens, and 1:1 visual parity with designs. + +## Prerequisites + +- Figma MCP server must be connected and accessible +- User must provide a Figma URL in the format: `https://figma.com/design/:fileKey/:fileName?node-id=1-2` + - `:fileKey` is the file key + - `1-2` is the node ID (the specific component or frame to implement) +- **OR** when using `figma-desktop` MCP: User can select a node directly in the Figma desktop app (no URL required) +- Project should have an established design system or component library (preferred) + +## Required Workflow + +**Follow these steps in order. Do not skip steps.** + +### Step 1: Get Node ID + +#### Option A: Parse from Figma URL + +When the user provides a Figma URL, extract the file key and node ID to pass as arguments to MCP tools. + +**URL format:** `https://figma.com/design/:fileKey/:fileName?node-id=1-2` + +**Extract:** + +- **File key:** `:fileKey` (the segment after `/design/`) +- **Node ID:** `1-2` (the value of the `node-id` query parameter) + +**Note:** When using the local desktop MCP (`figma-desktop`), `fileKey` is not passed as a parameter to tool calls. The server automatically uses the currently open file, so only `nodeId` is needed. + +**Example:** + +- URL: `https://figma.com/design/kL9xQn2VwM8pYrTb4ZcHjF/DesignSystem?node-id=42-15` +- File key: `kL9xQn2VwM8pYrTb4ZcHjF` +- Node ID: `42-15` + +#### Option B: Use Current Selection from Figma Desktop App (figma-desktop MCP only) + +When using the `figma-desktop` MCP and the user has NOT provided a URL, the tools automatically use the currently selected node from the open Figma file in the desktop app. + +**Note:** Selection-based prompting only works with the `figma-desktop` MCP server. The remote server requires a link to a frame or layer to extract context. The user must have the Figma desktop app open with a node selected. + +### Step 2: Fetch Design Context + +Run `get_design_context` with the extracted file key and node ID. + +``` +get_design_context(fileKey=":fileKey", nodeId="1-2") +``` + +This provides the structured data including: + +- Layout properties (Auto Layout, constraints, sizing) +- Typography specifications +- Color values and design tokens +- Component structure and variants +- Spacing and padding values + +**If the response is too large or truncated:** + +1. Run `get_metadata(fileKey=":fileKey", nodeId="1-2")` to get the high-level node map +2. Identify the specific child nodes needed from the metadata +3. Fetch individual child nodes with `get_design_context(fileKey=":fileKey", nodeId=":childNodeId")` + +### Step 3: Capture Visual Reference + +Run `get_screenshot` with the same file key and node ID for a visual reference. + +``` +get_screenshot(fileKey=":fileKey", nodeId="1-2") +``` + +This screenshot serves as the source of truth for visual validation. Keep it accessible throughout implementation. + +### Step 4: Download Required Assets + +Download any assets (images, icons, SVGs) returned by the Figma MCP server. + +**IMPORTANT:** Follow these asset rules: + +- If the Figma MCP server returns a `localhost` source for an image or SVG, use that source directly +- DO NOT import or add new icon packages - all assets should come from the Figma payload +- DO NOT use or create placeholders if a `localhost` source is provided +- Assets are served through the Figma MCP server's built-in assets endpoint + +### Step 5: Translate to Project Conventions + +Translate the Figma output into this project's framework, styles, and conventions. + +**Key principles:** + +- Treat the Figma MCP output (typically React + Tailwind) as a representation of design and behavior, not as final code style +- Replace Tailwind utility classes with the project's preferred utilities or design system tokens +- Reuse existing components (buttons, inputs, typography, icon wrappers) instead of duplicating functionality +- Use the project's color system, typography scale, and spacing tokens consistently +- Respect existing routing, state management, and data-fetch patterns + +### Step 6: Achieve 1:1 Visual Parity + +Strive for pixel-perfect visual parity with the Figma design. + +**Guidelines:** + +- Prioritize Figma fidelity to match designs exactly +- Avoid hardcoded values - use design tokens from Figma where available +- When conflicts arise between design system tokens and Figma specs, prefer design system tokens but adjust spacing or sizes minimally to match visuals +- Follow WCAG requirements for accessibility +- Add component documentation as needed + +### Step 7: Validate Against Figma + +Before marking complete, validate the final UI against the Figma screenshot. + +**Validation checklist:** + +- [ ] Layout matches (spacing, alignment, sizing) +- [ ] Typography matches (font, size, weight, line height) +- [ ] Colors match exactly +- [ ] Interactive states work as designed (hover, active, disabled) +- [ ] Responsive behavior follows Figma constraints +- [ ] Assets render correctly +- [ ] Accessibility standards met + +## Implementation Rules + +### Component Organization + +- Place UI components in the project's designated design system directory +- Follow the project's component naming conventions +- Avoid inline styles unless truly necessary for dynamic values + +### Design System Integration + +- ALWAYS use components from the project's design system when possible +- Map Figma design tokens to project design tokens +- When a matching component exists, extend it rather than creating a new one +- Document any new components added to the design system + +### Code Quality + +- Avoid hardcoded values - extract to constants or design tokens +- Keep components composable and reusable +- Add TypeScript types for component props +- Include JSDoc comments for exported components + +## Examples + +### Example 1: Implementing a Button Component + +User says: "Implement this Figma button component: https://figma.com/design/kL9xQn2VwM8pYrTb4ZcHjF/DesignSystem?node-id=42-15" + +**Actions:** + +1. Parse URL to extract fileKey=`kL9xQn2VwM8pYrTb4ZcHjF` and nodeId=`42-15` +2. Run `get_design_context(fileKey="kL9xQn2VwM8pYrTb4ZcHjF", nodeId="42-15")` +3. Run `get_screenshot(fileKey="kL9xQn2VwM8pYrTb4ZcHjF", nodeId="42-15")` for visual reference +4. Download any button icons from the assets endpoint +5. Check if project has existing button component +6. If yes, extend it with new variant; if no, create new component using project conventions +7. Map Figma colors to project design tokens (e.g., `primary-500`, `primary-hover`) +8. Validate against screenshot for padding, border radius, typography + +**Result:** Button component matching Figma design, integrated with project design system. + +### Example 2: Building a Dashboard Layout + +User says: "Build this dashboard: https://figma.com/design/pR8mNv5KqXzGwY2JtCfL4D/Dashboard?node-id=10-5" + +**Actions:** + +1. Parse URL to extract fileKey=`pR8mNv5KqXzGwY2JtCfL4D` and nodeId=`10-5` +2. Run `get_metadata(fileKey="pR8mNv5KqXzGwY2JtCfL4D", nodeId="10-5")` to understand the page structure +3. Identify main sections from metadata (header, sidebar, content area, cards) and their child node IDs +4. Run `get_design_context(fileKey="pR8mNv5KqXzGwY2JtCfL4D", nodeId=":childNodeId")` for each major section +5. Run `get_screenshot(fileKey="pR8mNv5KqXzGwY2JtCfL4D", nodeId="10-5")` for the full page +6. Download all assets (logos, icons, charts) +7. Build layout using project's layout primitives +8. Implement each section using existing components where possible +9. Validate responsive behavior against Figma constraints + +**Result:** Complete dashboard matching Figma design with responsive layout. + +## Best Practices + +### Always Start with Context + +Never implement based on assumptions. Always fetch `get_design_context` and `get_screenshot` first. + +### Incremental Validation + +Validate frequently during implementation, not just at the end. This catches issues early. + +### Document Deviations + +If you must deviate from the Figma design (e.g., for accessibility or technical constraints), document why in code comments. + +### Reuse Over Recreation + +Always check for existing components before creating new ones. Consistency across the codebase is more important than exact Figma replication. + +### Design System First + +When in doubt, prefer the project's design system patterns over literal Figma translation. + +## Common Issues and Solutions + +### Issue: Figma output is truncated + +**Cause:** The design is too complex or has too many nested layers to return in a single response. +**Solution:** Use `get_metadata` to get the node structure, then fetch specific nodes individually with `get_design_context`. + +### Issue: Design doesn't match after implementation + +**Cause:** Visual discrepancies between the implemented code and the original Figma design. +**Solution:** Compare side-by-side with the screenshot from Step 3. Check spacing, colors, and typography values in the design context data. + +### Issue: Assets not loading + +**Cause:** The Figma MCP server's assets endpoint is not accessible or the URLs are being modified. +**Solution:** Verify the Figma MCP server's assets endpoint is accessible. The server serves assets at `localhost` URLs. Use these directly without modification. + +### Issue: Design token values differ from Figma + +**Cause:** The project's design system tokens have different values than those specified in the Figma design. +**Solution:** When project tokens differ from Figma values, prefer project tokens for consistency but adjust spacing/sizing to maintain visual fidelity. + +## Understanding Design Implementation + +The Figma implementation workflow establishes a reliable process for translating designs to code: + +**For designers:** Confidence that implementations will match their designs with pixel-perfect accuracy. +**For developers:** A structured approach that eliminates guesswork and reduces back-and-forth revisions. +**For teams:** Consistent, high-quality implementations that maintain design system integrity. + +By following this workflow, you ensure that every Figma design is implemented with the same level of care and attention to detail. + +## Additional Resources + +- [Figma MCP Server Documentation](https://developers.figma.com/docs/figma-mcp-server/) +- [Figma MCP Server Tools and Prompts](https://developers.figma.com/docs/figma-mcp-server/tools-and-prompts/) +- [Figma Variables and Design Tokens](https://help.figma.com/hc/en-us/articles/15339657135383-Guide-to-variables-in-Figma) diff --git a/CLAUDE.md b/CLAUDE.md index d31a7714..618fcae5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,14 @@ # Points: GenLayer Testnet Program Tracking System +## ALWAYS Use Teammates (Teams), NEVER Single Tasks +**CRITICAL**: When delegating work to agents, ALWAYS use TeamCreate + Task with `team_name` to spawn **teammates**. NEVER use the Task tool as a standalone single agent. Every piece of work must go through a team: +1. Create a team with `TeamCreate` +2. Spawn teammates with `Task` using `team_name` parameter +3. Coordinate via `SendMessage` and `TaskList` +4. Shut down teammates and `TeamDelete` when done + +This applies to ALL work β backend, frontend, research, everything. No exceptions. + ## π Quick Reference Documentation **Important**: This project has detailed documentation for faster development: - **Backend Documentation**: See `backend/CLAUDE.md` for Django structure, API endpoints, models, and patterns diff --git a/backend/api/urls.py b/backend/api/urls.py index aa95b351..2d5162d1 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,7 +1,7 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter from users.views import UserViewSet -from contributions.views import ContributionTypeViewSet, ContributionViewSet, EvidenceViewSet, SubmittedContributionViewSet, StewardSubmissionViewSet, MissionViewSet, StartupRequestViewSet +from contributions.views import ContributionTypeViewSet, ContributionViewSet, EvidenceViewSet, SubmittedContributionViewSet, StewardSubmissionViewSet, MissionViewSet, StartupRequestViewSet, FeaturedContentViewSet, AlertViewSet from leaderboard.views import GlobalLeaderboardMultiplierViewSet, LeaderboardViewSet from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView from .metrics_views import ActiveValidatorsView, ContributionTypesStatsView, ParticipantsGrowthView @@ -18,6 +18,8 @@ router.register(r'steward-submissions', StewardSubmissionViewSet, basename='steward-submission') router.register(r'missions', MissionViewSet, basename='mission') router.register(r'startup-requests', StartupRequestViewSet, basename='startup-request') +router.register(r'featured', FeaturedContentViewSet, basename='featured') +router.register(r'alerts', AlertViewSet, basename='alert') # The API URLs are now determined automatically by the router urlpatterns = [ diff --git a/backend/builders/views.py b/backend/builders/views.py index 5e2a2934..1acbc7a4 100644 --- a/backend/builders/views.py +++ b/backend/builders/views.py @@ -2,10 +2,8 @@ from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated, AllowAny -from django.db.models import Min from .models import Builder from users.serializers import BuilderSerializer -from contributions.models import Contribution class BuilderViewSet(viewsets.ModelViewSet): @@ -72,6 +70,7 @@ def newest_builders(self, request): 'address': builder.user.address, 'name': builder.user.name, 'profile_image_url': builder.user.profile_image_url, + 'builder': True, 'created_at': builder.created_at }) diff --git a/backend/contributions/admin.py b/backend/contributions/admin.py index f41ccba1..846844ee 100644 --- a/backend/contributions/admin.py +++ b/backend/contributions/admin.py @@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError from django.contrib.auth import get_user_model from datetime import datetime -from .models import Category, ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission, StartupRequest, SubmissionNote +from .models import Category, ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission, StartupRequest, SubmissionNote, FeaturedContent, Alert from .validator_forms import CreateValidatorForm from leaderboard.models import GlobalLeaderboardMultiplier @@ -674,3 +674,71 @@ def get_status(self, obj): else: return format_html('β Inactive') get_status.short_description = 'Status' + + +@admin.register(FeaturedContent) +class FeaturedContentAdmin(admin.ModelAdmin): + list_display = ('title', 'content_type', 'user', 'is_active', 'order', 'created_at') + list_filter = ('content_type', 'is_active', 'created_at') + search_fields = ('title', 'description', 'user__name', 'user__address') + list_editable = ('order', 'is_active') + raw_id_fields = ('user', 'contribution') + readonly_fields = ('created_at', 'updated_at') + ordering = ('order', '-created_at') + + fieldsets = ( + (None, { + 'fields': ('content_type', 'title', 'subtitle', 'description', 'is_active', 'order') + }), + ('Relations', { + 'fields': ('user', 'contribution') + }), + ('Links & Media', { + 'fields': ('hero_image_url', 'hero_image_public_id', 'user_profile_image_url', 'user_profile_image_public_id', 'url'), + 'description': 'Paste Cloudinary URLs directly. Public IDs are used for image management.' + }), + ('Metadata', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + +@admin.register(Alert) +class AlertAdmin(admin.ModelAdmin): + list_display = ('id', 'alert_type', 'text_preview', 'get_status', 'order', 'start_date', 'end_date', 'created_at') + list_filter = ('alert_type', 'is_active', 'created_at') + search_fields = ('text',) + list_editable = ('order', 'alert_type') + readonly_fields = ('id', 'created_at', 'updated_at') + ordering = ('order', '-created_at') + + fieldsets = ( + ('Content', { + 'fields': ('id', 'alert_type', 'icon', 'text', 'is_active', 'order') + }), + ('Schedule', { + 'fields': ('start_date', 'end_date'), + 'description': 'Optional: Set dates to control when this alert is visible' + }), + ('Metadata', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + def text_preview(self, obj): + return obj.text[:80] + '...' if len(obj.text) > 80 else obj.text + text_preview.short_description = 'Text' + + def get_status(self, obj): + from django.utils import timezone as tz + now = tz.now() + if not obj.is_active: + return format_html('β Inactive') + if obj.start_date and now < obj.start_date: + return format_html('β Scheduled') + if obj.end_date and now > obj.end_date: + return format_html('β Expired') + return format_html('β Active') + get_status.short_description = 'Status' diff --git a/backend/contributions/management/commands/seed_featured_content.py b/backend/contributions/management/commands/seed_featured_content.py new file mode 100644 index 00000000..da123c37 --- /dev/null +++ b/backend/contributions/management/commands/seed_featured_content.py @@ -0,0 +1,179 @@ +import os + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +from contributions.models import FeaturedContent +from users.cloudinary_service import CloudinaryService + +User = get_user_model() + + +class Command(BaseCommand): + help = 'Seeds FeaturedContent entries for the portal home page (hero banner and featured builds).' + + def _upload_to_cloudinary(self, image_path, featured_obj, upload_type='hero'): + """ + Upload a local image file to Cloudinary and return the result dict. + Returns None if the file does not exist. + + Args: + image_path: Absolute path to the local image file + featured_obj: FeaturedContent instance (used for naming) + upload_type: 'hero' or 'avatar' + """ + if not os.path.exists(image_path): + self.stdout.write(self.style.WARNING(f" Image not found: {image_path}")) + return None + + try: + with open(image_path, 'rb') as f: + if upload_type == 'hero': + result = CloudinaryService.upload_featured_image(f, featured_obj.pk) + else: + result = CloudinaryService.upload_featured_avatar(f, featured_obj.pk) + self.stdout.write(self.style.SUCCESS(f" Uploaded {upload_type}: {result['url'][:80]}...")) + return result + except Exception as e: + self.stdout.write(self.style.ERROR(f" Cloudinary upload failed for {upload_type}: {e}")) + return None + + def handle(self, *args, **options): + media_root = settings.MEDIA_ROOT + + # ---------------------------------------------------------------- + # 1. Ensure users exist (get_or_create with dummy email/address) + # ---------------------------------------------------------------- + users = {} + user_specs = [ + { + 'name': 'cognocracy', + 'email': 'cognocracy@seed.genlayer.com', + 'address': '0x0000000000000000000000000000000000000001', + }, + { + 'name': 'raskovsky', + 'email': 'raskovsky@seed.genlayer.com', + 'address': '0x0000000000000000000000000000000000000002', + }, + { + 'name': 'GenLayer', + 'email': 'genlayer@seed.genlayer.com', + 'address': '0x0000000000000000000000000000000000000003', + }, + ] + + for spec in user_specs: + user, created = User.objects.get_or_create( + email=spec['email'], + defaults={ + 'name': spec['name'], + 'address': spec['address'], + 'username': spec['name'], + }, + ) + users[spec['name']] = user + if created: + self.stdout.write(self.style.SUCCESS(f" Created user: {spec['name']}")) + else: + self.stdout.write(f" User already exists: {spec['name']}") + + # ---------------------------------------------------------------- + # 2. Hero banner + # ---------------------------------------------------------------- + hero_defaults = { + 'description': 'Deploy intelligent contracts, run validators, and earn GenLayer Points on the latest testnet.', + 'subtitle': 'cognocracy', + 'user': users['cognocracy'], + 'hero_image_url': '', + 'url': '', + 'is_active': True, + 'order': 0, + } + + obj, created = FeaturedContent.objects.update_or_create( + content_type='hero', + title='Argue.fun Launch', + defaults=hero_defaults, + ) + + # Upload hero image to Cloudinary + hero_image_path = os.path.join(media_root, 'featured', 'hero-bg.png') + result = self._upload_to_cloudinary(hero_image_path, obj, 'hero') + if result: + obj.hero_image_url = result['url'] + obj.hero_image_public_id = result['public_id'] + obj.save() + + self.stdout.write( + self.style.SUCCESS(f" {'Created' if created else 'Updated'} hero: {obj.title}") + ) + + # ---------------------------------------------------------------- + # 3. Featured builds + # ---------------------------------------------------------------- + builds = [ + { + 'title': 'Argue.fun', + 'user': users['cognocracy'], + 'hero_image_file': os.path.join(media_root, 'featured', 'argue-fun-bg.jpg'), + 'avatar_file': os.path.join(media_root, 'featured', 'avatars', 'cognocracy-avatar.png'), + 'url': '', + 'order': 0, + }, + { + 'title': 'Internet Court', + 'user': users['raskovsky'], + 'hero_image_file': os.path.join(media_root, 'featured', 'internet-court-bg.jpg'), + 'avatar_file': os.path.join(media_root, 'featured', 'avatars', 'raskovsky-avatar.png'), + 'url': '', + 'order': 1, + }, + { + 'title': 'Rally', + 'user': users['GenLayer'], + 'hero_image_file': os.path.join(media_root, 'featured', 'rally-bg.jpg'), + 'avatar_file': os.path.join(media_root, 'featured', 'avatars', 'genlayer-avatar.png'), + 'url': '', + 'order': 2, + }, + ] + + for build in builds: + obj, created = FeaturedContent.objects.update_or_create( + content_type='build', + title=build['title'], + defaults={ + 'user': build['user'], + 'hero_image_url': '', + 'url': build['url'], + 'is_active': True, + 'order': build['order'], + 'description': '', + 'subtitle': '', + }, + ) + + # Upload hero image to Cloudinary + updated = False + result = self._upload_to_cloudinary(build['hero_image_file'], obj, 'hero') + if result: + obj.hero_image_url = result['url'] + obj.hero_image_public_id = result['public_id'] + updated = True + + # Upload avatar to Cloudinary + result = self._upload_to_cloudinary(build['avatar_file'], obj, 'avatar') + if result: + obj.user_profile_image_url = result['url'] + obj.user_profile_image_public_id = result['public_id'] + updated = True + + if updated: + obj.save() + + self.stdout.write( + self.style.SUCCESS(f" {'Created' if created else 'Updated'} build: {obj.title}") + ) + + self.stdout.write(self.style.SUCCESS('\nFeatured content seeded successfully.')) diff --git a/backend/contributions/migrations/0033_alter_submissionnote_options_featuredcontent.py b/backend/contributions/migrations/0033_alter_submissionnote_options_featuredcontent.py new file mode 100644 index 00000000..4e6f8413 --- /dev/null +++ b/backend/contributions/migrations/0033_alter_submissionnote_options_featuredcontent.py @@ -0,0 +1,41 @@ +# Generated by Django 6.0.2 on 2026-02-25 02:08 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contributions', '0032_submittedcontribution_proposal_fields_submissionnote'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='submissionnote', + options={'ordering': ['-created_at']}, + ), + migrations.CreateModel( + name='FeaturedContent', + 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)), + ('content_type', models.CharField(choices=[('hero', 'Hero Banner'), ('build', 'Featured Build'), ('community', 'Featured Community'), ('validator_steward', 'Featured Validator/Steward')], max_length=20)), + ('title', models.CharField(max_length=200)), + ('description', models.TextField(blank=True)), + ('subtitle', models.CharField(blank=True, max_length=200)), + ('hero_image_url', models.URLField(blank=True, max_length=500)), + ('url', models.URLField(blank=True, max_length=500)), + ('is_active', models.BooleanField(default=True)), + ('order', models.PositiveIntegerField(default=0)), + ('contribution', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='featured_items', to='contributions.contribution')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='featured_items', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['order', '-created_at'], + }, + ), + ] diff --git a/backend/contributions/migrations/0034_featuredcontent_image_fields.py b/backend/contributions/migrations/0034_featuredcontent_image_fields.py new file mode 100644 index 00000000..5055c43d --- /dev/null +++ b/backend/contributions/migrations/0034_featuredcontent_image_fields.py @@ -0,0 +1,31 @@ +# Generated by Django 6.0.2 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contributions', '0033_alter_submissionnote_options_featuredcontent'), + ] + + operations = [ + # Rename hero_image_url -> hero_image_link to free up the name for the serializer + migrations.RenameField( + model_name='featuredcontent', + old_name='hero_image_url', + new_name='hero_image_link', + ), + # Add hero_image FileField for uploaded background images + migrations.AddField( + model_name='featuredcontent', + name='hero_image', + field=models.FileField(blank=True, null=True, upload_to='featured/'), + ), + # Add user_profile_image FileField for user avatar uploads + migrations.AddField( + model_name='featuredcontent', + name='user_profile_image', + field=models.FileField(blank=True, null=True, upload_to='featured/avatars/'), + ), + ] diff --git a/backend/contributions/migrations/0035_remove_featuredcontent_hero_image_and_more.py b/backend/contributions/migrations/0035_remove_featuredcontent_hero_image_and_more.py new file mode 100644 index 00000000..0d37c444 --- /dev/null +++ b/backend/contributions/migrations/0035_remove_featuredcontent_hero_image_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 6.0.2 on 2026-02-25 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contributions', '0034_featuredcontent_image_fields'), + ] + + operations = [ + # Remove FileFields (no longer needed, replaced by Cloudinary URLs) + migrations.RemoveField( + model_name='featuredcontent', + name='hero_image', + ), + migrations.RemoveField( + model_name='featuredcontent', + name='user_profile_image', + ), + # Rename hero_image_link back to hero_image_url (was renamed in migration 0034) + migrations.RenameField( + model_name='featuredcontent', + old_name='hero_image_link', + new_name='hero_image_url', + ), + # Update hero_image_url field definition to match new model + migrations.AlterField( + model_name='featuredcontent', + name='hero_image_url', + field=models.URLField(blank=True, help_text='Cloudinary URL for hero/background image', max_length=500), + ), + # Add new Cloudinary fields + migrations.AddField( + model_name='featuredcontent', + name='hero_image_public_id', + field=models.CharField(blank=True, help_text='Cloudinary public ID for hero image', max_length=255), + ), + migrations.AddField( + model_name='featuredcontent', + name='user_profile_image_url', + field=models.URLField(blank=True, help_text='Cloudinary URL for user avatar in featured card', max_length=500), + ), + migrations.AddField( + model_name='featuredcontent', + name='user_profile_image_public_id', + field=models.CharField(blank=True, help_text='Cloudinary public ID for user avatar', max_length=255), + ), + ] diff --git a/backend/contributions/migrations/0036_alert.py b/backend/contributions/migrations/0036_alert.py new file mode 100644 index 00000000..45176f12 --- /dev/null +++ b/backend/contributions/migrations/0036_alert.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.2 on 2026-02-26 00:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contributions', '0035_remove_featuredcontent_hero_image_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Alert', + 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)), + ('alert_type', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('success', 'Success')], default='info', max_length=10)), + ('icon', models.CharField(blank=True, help_text='Optional icon name (frontend defaults by type)', max_length=50)), + ('text', models.TextField(help_text='Alert message text')), + ('is_active', models.BooleanField(default=True)), + ('order', models.PositiveIntegerField(default=0, help_text='Display order (lower numbers appear first)')), + ('start_date', models.DateTimeField(blank=True, help_text='When this alert becomes visible (optional)', null=True)), + ('end_date', models.DateTimeField(blank=True, help_text='When this alert expires (optional)', null=True)), + ], + options={ + 'verbose_name': 'Alert', + 'verbose_name_plural': 'Alerts', + 'ordering': ['order', '-created_at'], + }, + ), + ] diff --git a/backend/contributions/models.py b/backend/contributions/models.py index d7458134..9d79eca0 100644 --- a/backend/contributions/models.py +++ b/backend/contributions/models.py @@ -637,4 +637,94 @@ def get_active_requests(cls): """ Get all active startup requests ordered by display order. """ - return cls.objects.filter(is_active=True).order_by('order', '-created_at') \ No newline at end of file + return cls.objects.filter(is_active=True).order_by('order', '-created_at') + + +class FeaturedContent(BaseModel): + """ + Featured content for the home page: hero banners, featured builds, community highlights. + Managed via admin panel. + """ + CONTENT_TYPE_CHOICES = [ + ('hero', 'Hero Banner'), + ('build', 'Featured Build'), + ('community', 'Featured Community'), + ('validator_steward', 'Featured Validator/Steward'), + ] + content_type = models.CharField(max_length=20, choices=CONTENT_TYPE_CHOICES) + title = models.CharField(max_length=200) + description = models.TextField(blank=True) + subtitle = models.CharField(max_length=200, blank=True) + contribution = models.ForeignKey( + 'Contribution', on_delete=models.SET_NULL, null=True, blank=True, + related_name='featured_items' + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, + related_name='featured_items' + ) + hero_image_url = models.URLField(max_length=500, blank=True, help_text="Cloudinary URL for hero/background image") + hero_image_public_id = models.CharField(max_length=255, blank=True, help_text="Cloudinary public ID for hero image") + user_profile_image_url = models.URLField(max_length=500, blank=True, help_text="Cloudinary URL for user avatar in featured card") + user_profile_image_public_id = models.CharField(max_length=255, blank=True, help_text="Cloudinary public ID for user avatar") + url = models.URLField(max_length=500, blank=True) + is_active = models.BooleanField(default=True) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ['order', '-created_at'] + + def __str__(self): + return f"{self.get_content_type_display()}: {self.title}" + + def get_link(self): + if self.contribution_id: + return f"/badge/{self.contribution_id}" + return self.url or None + + @classmethod + def get_active_by_type(cls, content_type, limit=10): + return cls.objects.filter( + content_type=content_type, is_active=True + ).select_related( + 'user', 'contribution', 'contribution__contribution_type' + ).order_by('order', '-created_at')[:limit] + + +class Alert(BaseModel): + """ + System-wide alert banners displayed on all pages. + Managed via Django admin. + """ + ALERT_TYPE_CHOICES = [ + ('info', 'Info'), + ('warning', 'Warning'), + ('error', 'Error'), + ('success', 'Success'), + ] + alert_type = models.CharField(max_length=10, choices=ALERT_TYPE_CHOICES, default='info') + icon = models.CharField(max_length=50, blank=True, help_text="Optional icon name (frontend defaults by type)") + text = models.TextField(help_text="Alert message text") + is_active = models.BooleanField(default=True) + order = models.PositiveIntegerField(default=0, help_text="Display order (lower numbers appear first)") + start_date = models.DateTimeField(null=True, blank=True, help_text="When this alert becomes visible (optional)") + end_date = models.DateTimeField(null=True, blank=True, help_text="When this alert expires (optional)") + + class Meta: + ordering = ['order', '-created_at'] + verbose_name = "Alert" + verbose_name_plural = "Alerts" + + def __str__(self): + return f"[{self.get_alert_type_display()}] {self.text[:50]}" + + @classmethod + def get_active_alerts(cls): + now = timezone.now() + return cls.objects.filter( + is_active=True + ).filter( + models.Q(start_date__isnull=True) | models.Q(start_date__lte=now) + ).filter( + models.Q(end_date__isnull=True) | models.Q(end_date__gt=now) + ) \ No newline at end of file diff --git a/backend/contributions/serializers.py b/backend/contributions/serializers.py index a6b3f948..cbd2342b 100644 --- a/backend/contributions/serializers.py +++ b/backend/contributions/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission, StartupRequest, SubmissionNote +from .models import ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission, StartupRequest, SubmissionNote, FeaturedContent, Alert from users.serializers import UserSerializer, LightUserSerializer from users.models import User from .recaptcha_field import ReCaptchaField @@ -716,3 +716,35 @@ class Meta: 'documents', 'is_active', 'order', 'created_at', 'updated_at' ] read_only_fields = ['id', 'created_at', 'updated_at'] + + +class FeaturedContentSerializer(serializers.ModelSerializer): + user_name = serializers.CharField(source='user.name', read_only=True) + user_address = serializers.CharField(source='user.address', read_only=True) + user_profile_image_url = serializers.SerializerMethodField() + link = serializers.SerializerMethodField() + + class Meta: + model = FeaturedContent + fields = ['id', 'content_type', 'title', 'description', 'subtitle', + 'hero_image_url', 'url', 'link', + 'user', 'user_name', 'user_address', 'user_profile_image_url', + 'contribution', 'is_active', 'order', 'created_at'] + + def get_user_profile_image_url(self, obj): + """Return the FeaturedContent's user_profile_image_url if set, otherwise fall back to user's profile_image_url.""" + if obj.user_profile_image_url: + return obj.user_profile_image_url + if obj.user and obj.user.profile_image_url: + return obj.user.profile_image_url + return '' + + def get_link(self, obj): + return obj.get_link() + + +class AlertSerializer(serializers.ModelSerializer): + class Meta: + model = Alert + fields = ['id', 'alert_type', 'icon', 'text', 'order', 'created_at'] + read_only_fields = ['id', 'created_at'] diff --git a/backend/contributions/views.py b/backend/contributions/views.py index fc10bc19..541d5578 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -7,19 +7,19 @@ from django.db.models import Count, Max, F, Q, Exists, OuterRef, Subquery, Sum from django.db.models.functions import TruncDate, TruncWeek, TruncMonth from django.db.models.functions import Coalesce -from django.db.models.functions import Coalesce from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib import messages from django.views.generic import ListView from django.utils.decorators import method_decorator -from .models import ContributionType, Contribution, Evidence, SubmittedContribution, ContributionHighlight, Mission, StartupRequest +from .models import ContributionType, Contribution, Evidence, SubmittedContribution, ContributionHighlight, Mission, StartupRequest, FeaturedContent, Alert from .serializers import (ContributionTypeSerializer, ContributionSerializer, EvidenceSerializer, SubmittedContributionSerializer, SubmittedEvidenceSerializer, ContributionHighlightSerializer, StewardSubmissionSerializer, StewardSubmissionReviewSerializer, SubmissionNoteSerializer, SubmissionProposeSerializer, - MissionSerializer, StartupRequestListSerializer, StartupRequestDetailSerializer) + MissionSerializer, StartupRequestListSerializer, StartupRequestDetailSerializer, + FeaturedContentSerializer, AlertSerializer) from .forms import SubmissionReviewForm from .permissions import IsSteward, steward_has_permission, steward_permitted_type_ids from leaderboard.models import GlobalLeaderboardMultiplier @@ -1597,7 +1597,7 @@ class MissionViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): """ - Return active highlights by default. + Return active missions by default. Supports filtering by contribution_type and category. """ from django.utils import timezone @@ -1645,3 +1645,29 @@ def get_serializer_class(self): if self.action == 'retrieve': return StartupRequestDetailSerializer return StartupRequestListSerializer + + +class FeaturedContentViewSet(viewsets.ReadOnlyModelViewSet): + """Featured content for the portal home page.""" + serializer_class = FeaturedContentSerializer + permission_classes = [permissions.AllowAny] + pagination_class = None + + def get_queryset(self): + queryset = FeaturedContent.objects.filter(is_active=True).select_related( + 'user', 'contribution', 'contribution__contribution_type' + ).order_by('order', '-created_at') + content_type = self.request.query_params.get('type') + if content_type: + queryset = queryset.filter(content_type=content_type) + return queryset + + +class AlertViewSet(viewsets.ReadOnlyModelViewSet): + """Public endpoint for active system alerts.""" + serializer_class = AlertSerializer + permission_classes = [permissions.AllowAny] + pagination_class = None + + def get_queryset(self): + return Alert.get_active_alerts() diff --git a/backend/leaderboard/views.py b/backend/leaderboard/views.py index 14560ee2..81bd114a 100644 --- a/backend/leaderboard/views.py +++ b/backend/leaderboard/views.py @@ -2,11 +2,11 @@ from rest_framework.decorators import action from rest_framework.response import Response from django.utils import timezone -from django.db.models import Count, Q +from django.db.models import Count, Q, Sum from django_filters.rest_framework import DjangoFilterBackend from .models import GlobalLeaderboardMultiplier, LeaderboardEntry, update_all_ranks, recalculate_all_leaderboards, LEADERBOARD_CONFIG from .serializers import GlobalLeaderboardMultiplierSerializer, LeaderboardEntrySerializer -from contributions.models import Category +from contributions.models import Category, Contribution class GlobalLeaderboardMultiplierViewSet(viewsets.ReadOnlyModelViewSet): @@ -216,10 +216,29 @@ def stats(self, request): total=Sum('frozen_global_points') )['total'] or 0 + # Category-specific counts (always included) + builder_count = LeaderboardEntry.objects.filter( + type='builder', user__visible=True + ).count() + validator_count = LeaderboardEntry.objects.filter( + type='validator', user__visible=True + ).count() + + from .models import ReferralPoints + from django.db.models import F + creator_count = ReferralPoints.objects.filter( + user__visible=True + ).annotate( + total_pts=F('builder_points') + F('validator_points') + ).filter(total_pts__gt=0).count() + return Response({ 'participant_count': participant_count, 'contribution_count': contribution_count, 'total_points': total_points, + 'builder_count': builder_count, + 'validator_count': validator_count, + 'creator_count': creator_count, }) def _get_user_stats(self, user, category=None): @@ -403,9 +422,9 @@ def types(self, request): return Response(types_info) @action(detail=False, methods=['get']) - def supporters(self, request): + def community(self, request): """ - Get supporters statistics and all supporters with referral points. + Get community statistics and all community members with referral points. Returns users with referrals sorted by total referral points. """ from .models import ReferralPoints @@ -418,15 +437,15 @@ def supporters(self, request): ).filter(user__visible=True, total_points__gt=0).order_by('-total_points') # Calculate aggregate stats - total_supporters = referral_points.count() + total_community = referral_points.count() total_builder_points = sum(rp.builder_points for rp in referral_points) total_validator_points = sum(rp.validator_points for rp in referral_points) - # Get all supporters - top_supporters = [] + # Get all community members + top_community = [] for rp in referral_points: user_data = UserSerializer(rp.user).data - top_supporters.append({ + top_community.append({ **user_data, 'builder_points': rp.builder_points, 'validator_points': rp.validator_points, @@ -434,9 +453,71 @@ def supporters(self, request): }) return Response({ - 'total_supporters': total_supporters, + 'total_community': total_community, 'total_builder_points': total_builder_points, 'total_validator_points': total_validator_points, - 'top_supporters': top_supporters + 'top_community': top_community }) + + @action(detail=False, methods=['get']) + def trending(self, request): + """ + Get users who earned the most points recently. + Tries last 30 days first; falls back to all-time if no data. + """ + try: + limit = int(request.query_params.get('limit', 10)) + except (ValueError, TypeError): + limit = 10 + limit = min(max(limit, 1), 100) + + from datetime import timedelta + from users.models import User + + cutoff = timezone.now() - timedelta(days=30) + + # Aggregate points per user from last 30 days + trending_users = list( + Contribution.objects.filter( + created_at__gte=cutoff, + user__visible=True, + ) + .values('user_id') + .annotate(total_points=Sum('frozen_global_points')) + .order_by('-total_points')[:limit] + ) + + # Fall back to all-time if no recent data + if not trending_users: + trending_users = list( + Contribution.objects.filter(user__visible=True) + .values('user_id') + .annotate(total_points=Sum('frozen_global_points')) + .order_by('-total_points')[:limit] + ) + + user_ids = [entry['user_id'] for entry in trending_users] + points_map = {entry['user_id']: entry['total_points'] for entry in trending_users} + + users = User.objects.filter(id__in=user_ids).select_related( + 'builder', 'validator', 'steward' + ) + users_by_id = {u.id: u for u in users} + + results = [] + for user_id in user_ids: + user = users_by_id.get(user_id) + if not user: + continue + results.append({ + 'user_name': user.name or '', + 'user_address': user.address or '', + 'profile_image_url': user.profile_image_url or '', + 'total_points': points_map[user_id] or 0, + 'builder': hasattr(user, 'builder'), + 'validator': hasattr(user, 'validator'), + 'steward': hasattr(user, 'steward'), + }) + + return Response(results) diff --git a/backend/users/cloudinary_service.py b/backend/users/cloudinary_service.py index 8e5c1f2e..1da9e361 100644 --- a/backend/users/cloudinary_service.py +++ b/backend/users/cloudinary_service.py @@ -149,6 +149,86 @@ def upload_banner_image(cls, image_file, user_id: int) -> Dict: ) raise + @classmethod + def upload_featured_image(cls, image_file, featured_id) -> Dict: + """ + Upload a featured content hero/background image to Cloudinary. + + Args: + image_file: The image file to upload + featured_id: The FeaturedContent ID for unique naming + + Returns: + Dict with 'url' and 'public_id' + """ + try: + cls.configure() + + upload_preset = getattr(settings, 'CLOUDINARY_UPLOAD_PRESET', 'tally_unsigned') + timestamp = int(time.time()) + + with trace_external('cloudinary', 'upload_featured_image'): + result = cloudinary.uploader.unsigned_upload( + image_file, + upload_preset, + public_id=f"featured_{featured_id}_hero_{timestamp}", + folder="tally/featured" + ) + + return { + 'url': result.get('secure_url', ''), + 'public_id': result.get('public_id', ''), + } + + except Exception as e: + logger.error(f"Failed to upload featured image: {str(e)}") + if "Upload preset not found" in str(e): + raise Exception( + "Cloudinary upload preset not configured. Please create an unsigned upload preset " + "named 'tally_unsigned' in your Cloudinary dashboard (Settings > Upload > Upload presets)." + ) + raise + + @classmethod + def upload_featured_avatar(cls, image_file, featured_id) -> Dict: + """ + Upload a featured content user avatar to Cloudinary. + + Args: + image_file: The image file to upload + featured_id: The FeaturedContent ID for unique naming + + Returns: + Dict with 'url' and 'public_id' + """ + try: + cls.configure() + + upload_preset = getattr(settings, 'CLOUDINARY_UPLOAD_PRESET', 'tally_unsigned') + timestamp = int(time.time()) + + with trace_external('cloudinary', 'upload_featured_avatar'): + result = cloudinary.uploader.unsigned_upload( + image_file, + upload_preset, + public_id=f"featured_{featured_id}_avatar_{timestamp}", + folder="tally/featured/avatars" + ) + + return { + 'url': result.get('secure_url', ''), + 'public_id': result.get('public_id', ''), + } + + except Exception as e: + logger.error(f"Failed to upload featured avatar: {str(e)}") + if "Upload preset not found" in str(e): + raise Exception( + "Cloudinary upload preset not configured. Please create an unsigned upload preset " + "named 'tally_unsigned' in your Cloudinary dashboard (Settings > Upload > Upload presets)." + ) + raise + @classmethod def delete_image(cls, public_id: str) -> bool: """ diff --git a/backend/users/views.py b/backend/users/views.py index 6eff19e9..ce2fb71f 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -1,4 +1,4 @@ -from rest_framework import viewsets, permissions, status +from rest_framework import viewsets, permissions, status, filters from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated @@ -32,6 +32,8 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = UserSerializer permission_classes = [permissions.AllowAny] # Allow read-only access without authentication lookup_field = 'address' # Change default lookup field from 'pk' to 'address' + filter_backends = [filters.OrderingFilter] + ordering_fields = ['date_joined', 'created_at'] def get_object(self): """ @@ -56,6 +58,9 @@ def get_serializer_context(self): context['use_light_serializers'] = self.action == 'list' # Include referral_details only for detail/by_address views context['include_referral_details'] = self.action in ['retrieve', 'by_address'] + # Pass visible flag for create action + if self.action == 'create' and 'visible' in self.request.data: + context['visible'] = self.request.data.get('visible') return context @action(detail=False, methods=['get'], url_path='by-address/(?P
[^/.]+)') @@ -111,12 +116,6 @@ def get_serializer_class(self): return UserCreateSerializer return UserSerializer - def get_serializer_context(self): - context = super().get_serializer_context() - if self.action == 'create' and 'visible' in self.request.data: - context['visible'] = self.request.data.get('visible') - return context - @action(detail=False, methods=['get', 'patch'], permission_classes=[permissions.IsAuthenticated]) def me(self, request): """ @@ -772,7 +771,7 @@ def referrals(self, request): 'referrals': referral_list }) - @action(detail=False, methods=['get'], permission_classes=[IsAuthenticated]) + @action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny]) def search(self, request): """Search users by name, address, email, or social handles.""" query = request.query_params.get('q', '').strip() diff --git a/backend/validators/views.py b/backend/validators/views.py index 179f2e0c..8f55347c 100644 --- a/backend/validators/views.py +++ b/backend/validators/views.py @@ -97,6 +97,7 @@ def newest_validators(self, request): user = users_dict.get(validator['user']) if user: user_data = LightUserSerializer(user).data + user_data['validator'] = True user_data['first_uptime_date'] = validator['first_uptime_date'] result.append(user_data) else: @@ -104,6 +105,7 @@ def newest_validators(self, request): result.append({ 'address': validator['user__address'], 'name': validator['user__name'], + 'validator': True, 'first_uptime_date': validator['first_uptime_date'] }) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 071b366c..1b477428 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -194,12 +194,20 @@ The application uses custom variable fonts for better visual hierarchy: - Use `font-body` class only in exceptional cases (very rare) - Supports all weights and italic variants +**Display/Numbers:** F37 Lineca VF (variable font, all weights) +- Use `font-display` class for large display text and stat numbers +- Used in: "Points" in header, hero titles ("Argue.fun Launch"), LiveStats numbers, PointsAwarded titles, CTAFooter headline +- Falls back to Geist if F37 Lineca fails to load +- Variable font supports all weights (`font-light` through `font-bold`) +- Figma uses Medium weight β use `font-medium` with `font-display` + **To change fonts globally:** Update CSS custom properties in `src/styles.css`: ```css :root { --font-heading: 'Geist', sans-serif; --font-body: 'Switzer', sans-serif; + --font-display: 'F37 Lineca', 'Geist', sans-serif; } ``` @@ -211,8 +219,8 @@ Update CSS custom properties in `src/styles.css`: // β Title in non-heading element - add font-heading{totalPoints}
+// β Large display numbers - use font-display (F37 Lineca) +{totalPoints}
// β Body text - automatic font-body (no class needed)Description text
@@ -556,18 +564,16 @@ Components like `RecentContributions`, `HighlightedContributions`, `UserContribu The only exception is when you need to group multiple related elements that aren't already in a component. ### Gradients Policy -**NO GRADIENTS**: Avoid using gradient backgrounds in the UI. Use solid colors instead for a cleaner, more professional look. +Gradients are allowed when they match the Figma design. Use Tailwind gradient utilities (`bg-gradient-to-r`, etc.) to implement them. ```javascript -// β WRONG - Don't use gradients +// β CORRECT - Use gradients when they match the Figma design- Contribution reviews are currently delayed. All submissions are being recorded and will be reviewed shortly. Thank you for your patience and continued participation. -
-