From ba917b818831ae7fd755d2b2d1e43b51ea75e3e2 Mon Sep 17 00:00:00 2001 From: Miya25 Date: Tue, 30 Dec 2025 15:06:20 +0530 Subject: [PATCH 1/6] feat(fix): fixes sigh --- packages/components/profile/public-profile.tsx | 2 +- packages/components/profile/referrals.tsx | 2 +- packages/lib/emails/templates/account-change.tsx | 6 +++--- packages/lib/emails/templates/admin-broadcast.tsx | 4 ++-- packages/lib/emails/templates/basic.tsx | 2 +- packages/lib/emails/templates/new-login.tsx | 4 ++-- packages/lib/emails/templates/password-reset.tsx | 4 ++-- packages/lib/emails/templates/perk-gained.tsx | 4 ++-- packages/lib/emails/templates/quota-reached.tsx | 4 ++-- packages/lib/emails/templates/storage-assigned.tsx | 4 ++-- packages/lib/emails/templates/verification-code.tsx | 2 +- packages/lib/emails/templates/welcome.tsx | 8 ++++---- packages/lib/events/handlers/email.ts | 2 +- 13 files changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/components/profile/public-profile.tsx b/packages/components/profile/public-profile.tsx index 37f34d8..89f8462 100644 --- a/packages/components/profile/public-profile.tsx +++ b/packages/components/profile/public-profile.tsx @@ -689,7 +689,7 @@ export function PublicProfile({ user, storageBonus, domainBonus, linkedAccounts, {files.map((file) => ( { if (!stats?.referralCode) return - const referralUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'https://emberly.dev'}/auth/register?ref=${stats.referralCode}` + const referralUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'https://embrly.ca'}/auth/register?ref=${stats.referralCode}` const shareText = `Join Emberly and get $10 in billing credits! Use my referral link: ${referralUrl}` if (navigator.share) { diff --git a/packages/lib/emails/templates/account-change.tsx b/packages/lib/emails/templates/account-change.tsx index 21f58fa..c3204c9 100644 --- a/packages/lib/emails/templates/account-change.tsx +++ b/packages/lib/emails/templates/account-change.tsx @@ -25,8 +25,8 @@ interface AccountChangeEmailProps { export function AccountChangeEmail({ userName, changes, - manageUrl = 'https://emberly.dev/dashboard/profile', - supportUrl = 'https://emberly.dev/contact', + manageUrl = 'https://embrly.ca/dashboard/profile', + supportUrl = 'https://embrly.ca/contact', }: AccountChangeEmailProps) { return ( @@ -40,7 +40,7 @@ export function AccountChangeEmail({
- + Emberly diff --git a/packages/lib/emails/templates/admin-broadcast.tsx b/packages/lib/emails/templates/admin-broadcast.tsx index 03facdb..2c0a690 100644 --- a/packages/lib/emails/templates/admin-broadcast.tsx +++ b/packages/lib/emails/templates/admin-broadcast.tsx @@ -45,7 +45,7 @@ export function AdminBroadcastEmail({
- + Emberly @@ -109,7 +109,7 @@ export function AdminBroadcastEmail({ Questions? Please reach out to the Emberly team via{' '} contact diff --git a/packages/lib/emails/templates/basic.tsx b/packages/lib/emails/templates/basic.tsx index ffa0d3d..994e4c4 100644 --- a/packages/lib/emails/templates/basic.tsx +++ b/packages/lib/emails/templates/basic.tsx @@ -44,7 +44,7 @@ export function BasicEmail({
- + Emberly diff --git a/packages/lib/emails/templates/new-login.tsx b/packages/lib/emails/templates/new-login.tsx index 882f314..b009c4e 100644 --- a/packages/lib/emails/templates/new-login.tsx +++ b/packages/lib/emails/templates/new-login.tsx @@ -26,7 +26,7 @@ export function NewLoginEmail({ loginLocation = 'Unknown location', loginTime = new Date().toLocaleString(), loginDevice = 'Unknown device', - reviewUrl = 'https://emberly.dev/dashboard/security', + reviewUrl = 'https://embrly.ca/dashboard/profile?tab=security', }: NewLoginEmailProps) { return ( @@ -40,7 +40,7 @@ export function NewLoginEmail({
- + Emberly diff --git a/packages/lib/emails/templates/password-reset.tsx b/packages/lib/emails/templates/password-reset.tsx index dea22fb..39e50fd 100644 --- a/packages/lib/emails/templates/password-reset.tsx +++ b/packages/lib/emails/templates/password-reset.tsx @@ -36,7 +36,7 @@ export function PasswordResetEmail({
- + Emberly @@ -112,7 +112,7 @@ export function PasswordResetEmail({ Didn't request this? If you didn't request a password reset, you can safely ignore this email or{' '} contact support diff --git a/packages/lib/emails/templates/perk-gained.tsx b/packages/lib/emails/templates/perk-gained.tsx index 85c65dd..675de49 100644 --- a/packages/lib/emails/templates/perk-gained.tsx +++ b/packages/lib/emails/templates/perk-gained.tsx @@ -28,7 +28,7 @@ export function PerkGainedEmail({ perkDescription, perkIcon = '🎉', expiresAt, - viewUrl = 'https://emberly.dev/profile', + viewUrl = 'https://embrly.ca/dashboard/profile', }: PerkGainedEmailProps) { const expiryDate = expiresAt ? new Date(expiresAt).toLocaleDateString('en-US', { year: 'numeric', @@ -48,7 +48,7 @@ export function PerkGainedEmail({
- + Emberly diff --git a/packages/lib/emails/templates/quota-reached.tsx b/packages/lib/emails/templates/quota-reached.tsx index 8c6750b..5b19e40 100644 --- a/packages/lib/emails/templates/quota-reached.tsx +++ b/packages/lib/emails/templates/quota-reached.tsx @@ -28,7 +28,7 @@ export function QuotaReachedEmail({ quotaLimit, percentage, unit = 'GB', - dashboardUrl = 'https://emberly.dev/dashboard', + dashboardUrl = 'https://embrly.ca/dashboard', }: QuotaReachedEmailProps) { const percentageBar = Math.round(percentage / 5) // 20 segments for visual bar const filledSegments = Math.round(percentageBar) @@ -45,7 +45,7 @@ export function QuotaReachedEmail({
- + Emberly diff --git a/packages/lib/emails/templates/storage-assigned.tsx b/packages/lib/emails/templates/storage-assigned.tsx index 3a70519..3032644 100644 --- a/packages/lib/emails/templates/storage-assigned.tsx +++ b/packages/lib/emails/templates/storage-assigned.tsx @@ -24,7 +24,7 @@ interface StorageAssignedEmailProps { export function StorageAssignedEmail({ storageAmount, reason = 'as part of your subscription', - usageUrl = 'https://emberly.dev/dashboard/uploads', + usageUrl = 'https://embrly.ca/dashboard/', }: StorageAssignedEmailProps) { return ( @@ -38,7 +38,7 @@ export function StorageAssignedEmail({
- + Emberly diff --git a/packages/lib/emails/templates/verification-code.tsx b/packages/lib/emails/templates/verification-code.tsx index 1504651..ac42189 100644 --- a/packages/lib/emails/templates/verification-code.tsx +++ b/packages/lib/emails/templates/verification-code.tsx @@ -39,7 +39,7 @@ export function VerificationCodeEmail({
- + Emberly diff --git a/packages/lib/emails/templates/welcome.tsx b/packages/lib/emails/templates/welcome.tsx index f712cb5..9d74480 100644 --- a/packages/lib/emails/templates/welcome.tsx +++ b/packages/lib/emails/templates/welcome.tsx @@ -33,7 +33,7 @@ export function WelcomeEmail({ name = 'there', verificationUrl }: WelcomeEmailPr
- + Emberly @@ -114,7 +114,7 @@ export function WelcomeEmail({ name = 'there', verificationUrl }: WelcomeEmailPr Have questions?{' '} Get in touch @@ -138,14 +138,14 @@ export function WelcomeEmail({ name = 'there', verificationUrl }: WelcomeEmailPr Privacy {' | '} Terms diff --git a/packages/lib/events/handlers/email.ts b/packages/lib/events/handlers/email.ts index c57b3c7..0df4db7 100644 --- a/packages/lib/events/handlers/email.ts +++ b/packages/lib/events/handlers/email.ts @@ -80,7 +80,7 @@ async function sendEmail(options: { perkDescription: typeof variables.perkDescription === 'string' ? variables.perkDescription : undefined, perkIcon: typeof variables.perkIcon === 'string' ? variables.perkIcon : '🎉', expiresAt: typeof variables.expiresAt === 'string' ? variables.expiresAt : null, - viewUrl: typeof variables.viewUrl === 'string' ? variables.viewUrl : 'https://emberly.dev/profile', + viewUrl: typeof variables.viewUrl === 'string' ? variables.viewUrl : 'https://embrly.ca/dashboard/profile', }, skipTracking: true, }) From c05c896f138bca212f824db07e4575f003dc2b5a Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Thu, 1 Jan 2026 04:55:55 -0700 Subject: [PATCH 2/6] feat(add): status page and cleaned up stuff --- .env.template | 7 + CHANGELOG.md | 20 +- CODE_OF_CONDUCT.md | 133 ++ CONTRIBUTING.md | 315 +++++ app/(main)/blog/page.tsx | 32 +- app/(main)/globals.css | 64 + app/(main)/press/media-kit/layout.tsx | 10 + app/(main)/press/media-kit/page.tsx | 188 ++- app/(main)/press/page.tsx | 14 +- app/(main)/status/client.tsx | 128 ++ app/(main)/status/page.tsx | 184 +++ app/api/admin/themes/save/route.ts | 112 ++ app/api/legal/[id]/route.ts | 5 +- app/api/profile/route.ts | 1 + app/api/setup/route.ts | 2 +- app/api/status/components/route.ts | 21 + app/api/status/incidents/route.ts | 16 + app/api/status/maintenances/route.ts | 16 + app/api/status/route.ts | 35 +- app/globals.css | 63 + app/layout.tsx | 68 +- .../admin/settings/settings-manager.tsx | 29 +- .../appearance/appearance-panel.tsx | 360 ++++++ packages/components/dashboard/nav.tsx | 2 +- packages/components/layout/base-nav.tsx | 2 +- packages/components/profile/appearance.tsx | 129 +- .../providers/theme-provider-wrapper.tsx | 67 + packages/components/status/index.tsx | 1090 +++++++++++++++++ .../components/theme/animated-background.tsx | 529 ++++++++ .../components/theme/gaming-background.tsx | 167 +++ .../components/theme/theme-customizer.tsx | 388 +++++- .../theme/theme-effects-wrapper.tsx | 153 +++ .../components/theme/theme-initializer.tsx | 40 +- packages/lib/auth/api-auth.ts | 8 +- packages/lib/config/index.ts | 139 ++- packages/lib/files/upload-validation.ts | 5 +- packages/lib/instatus/index.ts | 350 ++++++ packages/lib/logger/index.ts | 18 +- packages/lib/middleware/constants.ts | 2 + packages/lib/permissions/index.ts | 194 +++ packages/lib/theme/index.ts | 86 ++ packages/lib/theme/theme-categories.ts | 91 ++ packages/lib/theme/theme-context.tsx | 428 +++++++ packages/lib/theme/theme-types.ts | 281 +++++ packages/types/dto/profile.ts | 1 + packages/types/instatus.ts | 316 +++++ .../migration.sql | 2 + prisma/schema.prisma | 1 + proxy.ts | 5 +- public/videos/site-preview-ad.mov | Bin 0 -> 5255185 bytes public/videos/uploading-ad.mov | Bin 0 -> 1625841 bytes scripts/migrate-config.js | 2 +- 52 files changed, 5973 insertions(+), 346 deletions(-) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 app/(main)/press/media-kit/layout.tsx create mode 100644 app/(main)/status/client.tsx create mode 100644 app/(main)/status/page.tsx create mode 100644 app/api/admin/themes/save/route.ts create mode 100644 app/api/status/components/route.ts create mode 100644 app/api/status/incidents/route.ts create mode 100644 app/api/status/maintenances/route.ts create mode 100644 packages/components/appearance/appearance-panel.tsx create mode 100644 packages/components/providers/theme-provider-wrapper.tsx create mode 100644 packages/components/status/index.tsx create mode 100644 packages/components/theme/animated-background.tsx create mode 100644 packages/components/theme/gaming-background.tsx create mode 100644 packages/components/theme/theme-effects-wrapper.tsx create mode 100644 packages/lib/instatus/index.ts create mode 100644 packages/lib/permissions/index.ts create mode 100644 packages/lib/theme/index.ts create mode 100644 packages/lib/theme/theme-categories.ts create mode 100644 packages/lib/theme/theme-context.tsx create mode 100644 packages/lib/theme/theme-types.ts create mode 100644 packages/types/instatus.ts create mode 100644 prisma/migrations/20251231102448_add_custom_colors_to_user/migration.sql create mode 100644 public/videos/site-preview-ad.mov create mode 100644 public/videos/uploading-ad.mov diff --git a/.env.template b/.env.template index bc6e8c1..3ecee1c 100644 --- a/.env.template +++ b/.env.template @@ -44,3 +44,10 @@ VIRUSTOTAL_API_KEY="your_virustotal_api_key" # Email Configuration (Resend) RESEND_API_KEY="re_your_resend_api_key" EMAIL_FROM="Your App " + +# Instatus API Configuration (for status page) +# Get API key from: User settings → Developer settings +# Get Page ID from: Dashboard → click <> button → Visit Page (auto-copied) +INSTATUS_API_KEY="your_instatus_api_key" +INSTATUS_PAGE_ID="your_instatus_page_id" +INSTATUS_STATUS_URL="https://status.yourdomain.com" diff --git a/CHANGELOG.md b/CHANGELOG.md index 85c9de9..e114e67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,26 @@ All notable changes to this project will be documented in this file. The format is based on "Keep a Changelog" and follows [Semantic Versioning](https://semver.org/). -## [1.4.0] - 2025-12-29 +## [1.4.0] - 2026-01-01 ### Added - **Public User API Access** - Added `/api/users` to public paths for contribution stats visibility. - Public profiles can now display GitHub contribution statistics without authentication. - Ensures contribution data is accessible for public profile pages. +- **Custom Status Page System** - Comprehensive system status page powered by Instatus API integration. + - New `/status` page displaying real-time service health, incidents, and maintenance windows. + - TypeScript types for full Instatus API coverage (`packages/types/instatus.ts`): `StatusSummary`, `StatusComponent`, `Incident`, `Maintenance`, and related interfaces. + - Instatus client library (`packages/lib/instatus/index.ts`) with public and authenticated API support. + - Supports both public API (`/summary.json`, `/v2/components.json`) and authenticated API (`/v2/:page_id/...`) with Bearer token. + - Environment variables: `INSTATUS_API_KEY`, `INSTATUS_PAGE_ID`, `INSTATUS_STATUS_URL` for configuration. + - API routes: `/api/status`, `/api/status/components`, `/api/status/incidents`, `/api/status/maintenances`. + - Status components: `StatusBadge`, `StatusIcon`, `StatusHeader`, `ComponentsList`, `ActiveIncidentsPanel`, `ActiveMaintenancesPanel`, `IncidentHistory`, `MaintenanceHistory`, `UptimeDisplay`, `StatusPageSkeleton`. + - Tabbed interface organizing Components, Incidents, and Maintenances with count badges. + - Glass-morphism styling consistent with rest of site using `GlassCard` components. + - Expandable incident/maintenance cards showing update timelines with HTML message support. + - Parent-child component hierarchy built dynamically from flat API responses using `group.id` references. + - Auto-refresh capability with manual refresh button and last-updated timestamps. + - Responsive design with mobile-optimized tab navigation. ### Changed - **Environment Variable Consolidation** - Unified domain configuration to use existing `NEXT_PUBLIC_BASE_URL`. @@ -39,6 +53,10 @@ The format is based on "Keep a Changelog" and follows [Semantic Versioning](http - Added individual try-catch blocks around commit detail fetches to prevent single failures from breaking entire stats. - Improved error handling with specific error logging per commit and repository. - Stats calculation (additions, deletions, files changed) now properly accumulates even with partial failures. +- **Press Pages Theme Compatibility** - Fixed hardcoded colors to respect active theme. + - Press page hero section now uses theme variables (`text-foreground`, `text-primary`) instead of hardcoded colors. + - Media kit color palette dynamically pulls from CSS variables to display actual theme colors. + - Color swatches show live theme values with proper hex code extraction from computed styles. ### Fixed - **Authentication System Domain Configuration** - Resolved production OAuth redirects incorrectly using `https://localhost:3000`. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..a5bafbb --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in the Emberly community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +### Positive Behavior + +Examples of behavior that contributes to a positive environment: + +- **Be Respectful** - Treat everyone with respect and consideration +- **Be Inclusive** - Welcome newcomers and help them get started +- **Be Constructive** - Provide helpful feedback and accept it gracefully +- **Be Collaborative** - Work together towards common goals +- **Be Patient** - Remember that everyone was a beginner once +- **Be Professional** - Maintain professional conduct in all interactions +- **Give Credit** - Acknowledge others' contributions and ideas +- **Focus on Community** - Prioritize what's best for the community + +### Unacceptable Behavior + +Examples of unacceptable behavior: + +- **Harassment** - Any form of harassment, intimidation, or discrimination +- **Trolling** - Inflammatory, derogatory, or insulting comments +- **Personal Attacks** - Ad hominem attacks or insults +- **Sexual Content** - Sexualized language, imagery, or unwelcome advances +- **Doxxing** - Publishing others' private information without permission +- **Spam** - Unsolicited promotional content or repetitive messages +- **Disruption** - Sustained disruption of discussions or events +- **Threats** - Threats of violence or violent language +- **Impersonation** - Pretending to be someone else + +## Scope + +This Code of Conduct applies within all community spaces, including: + +- GitHub repositories (issues, pull requests, discussions) +- Discord server +- Social media channels +- Community events (online and in-person) +- Direct communications related to the project + +This Code of Conduct also applies when an individual is officially representing the community in public spaces. + +## Enforcement + +### Reporting + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at: + +- **Email**: [hey@embrly.ca](mailto:hey@embrly.ca) +- **Discord**: Contact a moderator directly + +All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter. + +### What to Include + +When reporting, please include: + +1. Your contact information +2. Names (usernames) of individuals involved +3. Description of the behavior +4. Date and location (channel, repository, etc.) +5. Any additional context or screenshots +6. Whether you've previously contacted anyone about this + +### Confidentiality + +All reports will be handled with discretion. We will not disclose the identity of reporters without their explicit consent, except as required by law. + +## Enforcement Guidelines + +Community leaders will follow these guidelines in determining consequences: + +### 1. Correction + +**Community Impact**: Minor inappropriate behavior or first-time offense. + +**Consequence**: Private written warning with clarity around the violation and why it was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: Violation through a single incident or series of actions. + +**Consequence**: Warning with consequences for continued behavior. No interaction with involved parties for a specified period. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: Serious violation, including sustained inappropriate behavior. + +**Consequence**: Temporary ban from all community spaces for a specified period. No public or private interaction with community members during this time. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Pattern of violations, harassment, or aggression toward individuals or groups. + +**Consequence**: Permanent ban from all community interaction and spaces. + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior. They have the right and responsibility to: + +- Remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct +- Temporarily or permanently ban any contributor for behaviors deemed inappropriate, threatening, offensive, or harmful +- Communicate reasons for moderation decisions when appropriate + +## Appeals + +If you believe you have been falsely or unfairly accused of violating this Code of Conduct, you may appeal the decision by contacting [hey@embrly.ca](mailto:hey@embrly.ca) with a concise description of your grievance. Appeals will be reviewed by community leaders not involved in the original decision. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +--- + +## Questions? + +If you have questions about this Code of Conduct, please reach out: + +- **Email**: [hey@embrly.ca](mailto:hey@embrly.ca) +- **Discord**: [discord.gg/A8c58ScRCj](https://discord.gg/A8c58ScRCj) + +--- + +*Last updated: January 1, 2026* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b4a2364 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,315 @@ +# Contributing to Emberly + +Thank you for your interest in contributing to Emberly! This document provides guidelines and information for contributors. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Getting Started](#getting-started) +- [Development Setup](#development-setup) +- [How to Contribute](#how-to-contribute) +- [Pull Request Process](#pull-request-process) +- [Coding Standards](#coding-standards) +- [Commit Message Guidelines](#commit-message-guidelines) +- [Reporting Issues](#reporting-issues) +- [Community](#community) + +## Code of Conduct + +This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to [hey@embrly.ca](mailto:hey@embrly.ca). + +## Getting Started + +### Prerequisites + +- [Node.js](https://nodejs.org/) 18.x or later +- [Bun](https://bun.sh/) (recommended) or npm/yarn +- [PostgreSQL](https://www.postgresql.org/) 14.x or later +- [Redis](https://redis.io/) 6.x or later (optional, for caching) +- [Git](https://git-scm.com/) + +### Development Setup + +1. **Fork the repository** + + Click the "Fork" button on GitHub to create your own copy of the repository. + +2. **Clone your fork** + + ```bash + git clone https://github.com/YOUR_USERNAME/Website.git + cd Website + ``` + +3. **Add the upstream remote** + + ```bash + git remote add upstream https://github.com/EmberlyOSS/Website.git + ``` + +4. **Install dependencies** + + ```bash + bun install + ``` + +5. **Set up environment variables** + + ```bash + cp .env.template .env + ``` + + Edit `.env` with your local configuration. See the comments in `.env.template` for guidance. + +6. **Set up the database** + + ```bash + bun prisma generate + bun prisma db push + ``` + +7. **Start the development server** + + ```bash + bun dev + ``` + + The app will be available at `http://localhost:3000`. + +## How to Contribute + +### Types of Contributions + +We welcome many types of contributions: + +- **Bug fixes** - Help us squash bugs and improve stability +- **Features** - Implement new functionality (please discuss first) +- **Documentation** - Improve docs, fix typos, add examples +- **Tests** - Add or improve test coverage +- **Performance** - Optimize code and improve efficiency +- **Accessibility** - Make Emberly more accessible to everyone +- **Translations** - Help translate the interface (coming soon) + +### Before You Start + +1. **Check existing issues** - Someone may already be working on it +2. **Open a discussion** - For large features, discuss your approach first +3. **Create an issue** - For bugs or smaller features, create an issue to track work + +### Finding Issues to Work On + +Look for issues labeled: + +- `good first issue` - Great for newcomers +- `help wanted` - We'd love community help +- `bug` - Something isn't working correctly +- `enhancement` - New feature or improvement + +## Pull Request Process + +### 1. Create a Branch + +```bash +git checkout -b feature/your-feature-name +# or +git checkout -b fix/your-bug-fix +``` + +Branch naming conventions: +- `feature/` - New features +- `fix/` - Bug fixes +- `docs/` - Documentation changes +- `refactor/` - Code refactoring +- `test/` - Test additions or fixes +- `chore/` - Maintenance tasks + +### 2. Make Your Changes + +- Write clean, readable code +- Follow our [coding standards](#coding-standards) +- Add tests for new functionality +- Update documentation as needed + +### 3. Test Your Changes + +```bash +# Run the linter +bun lint + +# Run type checking +bun typecheck + +# Build the project +bun run build +``` + +### 4. Commit Your Changes + +Follow our [commit message guidelines](#commit-message-guidelines): + +```bash +git commit -m "feat: add user profile avatar upload" +``` + +### 5. Push and Create a Pull Request + +```bash +git push origin feature/your-feature-name +``` + +Then open a Pull Request on GitHub with: + +- A clear title describing the change +- A description of what changed and why +- Reference to any related issues (e.g., "Fixes #123") +- Screenshots for UI changes + +### 6. Code Review + +- Address reviewer feedback promptly +- Keep discussions constructive and respectful +- Make requested changes in new commits (we'll squash on merge) + +## Coding Standards + +### TypeScript + +- Use TypeScript for all new code +- Enable strict mode (`strict: true`) +- Prefer explicit types over `any` +- Use interfaces for object shapes + +```typescript +// ✅ Good +interface UserProfile { + id: string + name: string + email: string +} + +// ❌ Avoid +const user: any = { ... } +``` + +### React/Next.js + +- Use functional components with hooks +- Prefer server components when possible +- Use `'use client'` directive only when necessary +- Follow Next.js App Router conventions + +### Styling + +- Use Tailwind CSS for styling +- Follow the existing design system (shadcn/ui) +- Maintain responsive design (mobile-first) +- Support dark mode + +### File Organization + +``` +app/ # Next.js App Router pages +packages/ + ├── components/ # React components + ├── lib/ # Utility functions and libraries + ├── hooks/ # Custom React hooks + └── types/ # TypeScript type definitions +prisma/ # Database schema and migrations +public/ # Static assets +``` + +### Naming Conventions + +- **Files**: kebab-case (`user-profile.tsx`) +- **Components**: PascalCase (`UserProfile`) +- **Functions/Variables**: camelCase (`getUserProfile`) +- **Constants**: SCREAMING_SNAKE_CASE (`MAX_FILE_SIZE`) +- **Types/Interfaces**: PascalCase (`UserProfile`) + +## Commit Message Guidelines + +We use [Conventional Commits](https://www.conventionalcommits.org/) enforced by commitlint. + +### Format + +``` +(): + +[optional body] + +[optional footer] +``` + +### Types + +| Type | Description | +|------------|--------------------------------------------------| +| `feat` | New feature | +| `fix` | Bug fix | +| `docs` | Documentation changes | +| `style` | Formatting, missing semicolons, etc. | +| `refactor` | Code change that neither fixes nor adds features | +| `perf` | Performance improvement | +| `test` | Adding or updating tests | +| `chore` | Maintenance tasks, dependency updates | +| `ci` | CI/CD changes | +| `revert` | Reverting a previous commit | + +### Examples + +```bash +feat(auth): add two-factor authentication support +fix(upload): resolve file size validation error +docs(readme): update installation instructions +refactor(api): consolidate error handling +``` + +## Reporting Issues + +### Bug Reports + +When reporting bugs, please include: + +1. **Description** - Clear description of the issue +2. **Steps to Reproduce** - How to trigger the bug +3. **Expected Behavior** - What should happen +4. **Actual Behavior** - What actually happens +5. **Environment** - OS, browser, Node version +6. **Screenshots** - If applicable + +### Feature Requests + +For feature requests, please include: + +1. **Problem Statement** - What problem does this solve? +2. **Proposed Solution** - How would you implement it? +3. **Alternatives** - Other approaches considered +4. **Additional Context** - Mockups, examples, etc. + +## Community + +### Getting Help + +- **Discord** - Join our [Discord server](https://discord.gg/A8c58ScRCj) for real-time chat +- **GitHub Discussions** - For longer-form conversations +- **Issues** - For bug reports and feature requests + +### Recognition + +Contributors are recognized in several ways: + +- Listed in release notes for significant contributions +- Contributor badge on your Emberly profile +- Tiered perks based on contribution level (Bronze → Diamond) + +See our [Contributor Perks documentation](docs/CONTRIBUTOR_PERKS.md) for details. + +--- + +## License + +By contributing to Emberly, you agree that your contributions will be licensed under the same license as the project. + +--- + +Thank you for contributing to Emberly! 🔥 diff --git a/app/(main)/blog/page.tsx b/app/(main)/blog/page.tsx index b8131b2..a4c700c 100644 --- a/app/(main)/blog/page.tsx +++ b/app/(main)/blog/page.tsx @@ -42,29 +42,29 @@ export default async function BlogListPage() { {/* Featured badge for first post */} {index === 0 && ( -
- +
+ Latest
)} -
-
+
+
{/* Title */} -

+

{p.title}

{/* Excerpt */} {p.excerpt && ( -

+

{p.excerpt}

)} {/* Meta info */} -
+
{/* Author */} {p.author && (
@@ -72,14 +72,14 @@ export default async function BlogListPage() { {p.author.name ) : ( -
+
{(p.author.name || 'A').charAt(0)}
)} - + {p.author.name}
@@ -87,10 +87,10 @@ export default async function BlogListPage() { {/* Date */} {p.publishedAt && ( -
- +
+ {format(new Date(p.publishedAt), 'MMM d, yyyy')} - + ({formatDistanceToNow(new Date(p.publishedAt), { addSuffix: true })})
@@ -98,10 +98,10 @@ export default async function BlogListPage() {
- {/* Read more indicator */} -
+ {/* Read more indicator - visible on mobile, hover on desktop */} +
Read more - +
diff --git a/app/(main)/globals.css b/app/(main)/globals.css index 5685712..8cf1946 100644 --- a/app/(main)/globals.css +++ b/app/(main)/globals.css @@ -44,6 +44,7 @@ body { @apply glass rounded-lg shadow-lg; } + /* Custom Gradients */ .gradient-border { @apply border border-transparent bg-gradient-to-r from-primary/50 via-primary/25 to-transparent; @@ -101,3 +102,66 @@ body { scrollbar-width: thin; scrollbar-color: hsl(var(--muted-foreground)) transparent; } + +/* Theme: The Upside Down - Flips headers and adds eerie effects */ +[data-theme="upside-down"] h1, +[data-theme="upside-down"] h2, +[data-theme="upside-down"] nav, +[data-theme="upside-down"] .upside-down-target { + transform: scaleY(-1); +} + +[data-theme="upside-down"] { + /* Eerie vignette overlay */ + --upside-down-active: 1; +} + +[data-theme="upside-down"]::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9999; + background: radial-gradient(ellipse at center, transparent 40%, rgba(30, 0, 40, 0.4) 100%); + opacity: 0.6; +} + +/* Floating particles effect for upside-down */ +[data-theme="upside-down"] #theme-effects-root::after { + content: ''; + position: absolute; + inset: 0; + background-image: + radial-gradient(2px 2px at 20% 30%, rgba(255, 100, 100, 0.3), transparent), + radial-gradient(2px 2px at 40% 70%, rgba(100, 100, 255, 0.2), transparent), + radial-gradient(1px 1px at 70% 40%, rgba(255, 255, 255, 0.2), transparent), + radial-gradient(2px 2px at 90% 80%, rgba(200, 100, 150, 0.2), transparent); + animation: upside-down-particles 20s linear infinite; +} + +@keyframes upside-down-particles { + 0%, 100% { + transform: translateY(0) rotate(0deg); + opacity: 0.5; + } + 50% { + transform: translateY(-20px) rotate(180deg); + opacity: 0.8; + } +} + +/* Theme effects disabled state */ +[data-effects-disabled="true"] #theme-effects-root { + display: none !important; +} + +[data-effects-disabled="true"][data-theme="upside-down"] h1, +[data-effects-disabled="true"][data-theme="upside-down"] h2, +[data-effects-disabled="true"][data-theme="upside-down"] nav, +[data-effects-disabled="true"][data-theme="upside-down"] .upside-down-target { + transform: none; +} + +[data-effects-disabled="true"][data-theme="upside-down"]::before { + display: none; +} diff --git a/app/(main)/press/media-kit/layout.tsx b/app/(main)/press/media-kit/layout.tsx new file mode 100644 index 0000000..b2bb822 --- /dev/null +++ b/app/(main)/press/media-kit/layout.tsx @@ -0,0 +1,10 @@ +import { buildPageMetadata } from '@/packages/lib/embeds/metadata' + +export const metadata = buildPageMetadata({ + title: 'Media Kit', + description: 'Brand assets, logos, colors, and guidelines for press and partners.', +}) + +export default function MediaKitLayout({ children }: { children: React.ReactNode }) { + return children +} diff --git a/app/(main)/press/media-kit/page.tsx b/app/(main)/press/media-kit/page.tsx index 96b1803..e6d4737 100644 --- a/app/(main)/press/media-kit/page.tsx +++ b/app/(main)/press/media-kit/page.tsx @@ -1,3 +1,6 @@ +'use client' + +import { useEffect, useState } from 'react' import Image from 'next/image' import Link from 'next/link' @@ -62,12 +65,14 @@ const BRAND_ASSETS = [ }, ] -const BRAND_COLORS = [ - { name: 'Ember', value: '#F97316', rgb: '249, 115, 22', usage: 'Primary brand color, CTAs' }, - { name: 'Amber', value: '#F59E0B', rgb: '245, 158, 11', usage: 'Accents, highlights' }, - { name: 'Midnight', value: '#0F172A', rgb: '15, 23, 42', usage: 'Dark backgrounds' }, - { name: 'Slate', value: '#64748B', rgb: '100, 116, 139', usage: 'Body text, muted' }, - { name: 'Cream', value: '#F8FAFC', rgb: '248, 250, 252', usage: 'Light backgrounds' }, +// Theme-aware colors that pull from CSS variables +const THEME_COLORS = [ + { name: 'Primary', cssVar: '--primary', usage: 'Brand color, CTAs, links' }, + { name: 'Accent', cssVar: '--accent', usage: 'Highlights, badges' }, + { name: 'Background', cssVar: '--background', usage: 'Page backgrounds' }, + { name: 'Card', cssVar: '--card', usage: 'Card surfaces' }, + { name: 'Muted', cssVar: '--muted', usage: 'Subtle backgrounds' }, + { name: 'Foreground', cssVar: '--foreground', usage: 'Primary text' }, ] const TYPOGRAPHY = [ @@ -105,13 +110,6 @@ const CONTACT_POINTS = [ const KIT_DOWNLOAD = 'https://github.com/EmberlyOSS/Website/releases/latest' -import { buildPageMetadata } from '@/packages/lib/embeds/metadata' - -export const metadata = buildPageMetadata({ - title: 'Media Kit', - description: 'Brand assets, logos, colors, and guidelines for press and partners.', -}) - export default function MediaKitPage() { return ( @@ -172,30 +170,12 @@ export default function MediaKitPage() {
-

Color Palette

-
-
- {BRAND_COLORS.map((color) => ( -
-
- - {color.value} - -
-
-
{color.name}
-
RGB: {color.rgb}
-
{color.usage}
-
-
- ))} +

Theme Color Palette

+

+ Colors adapt to the active theme. These are the current theme's colors. +

+
@@ -361,15 +341,16 @@ type AssetPreviewProps = { function AssetPreview({ variant, theme }: AssetPreviewProps) { const baseClasses = 'flex h-40 w-full items-center justify-center rounded-xl border border-border/50' + // Use theme-aware backgrounds that adapt to the active theme const background = theme === 'dark' - ? 'bg-gradient-to-br from-background via-muted to-background' - : 'bg-gradient-to-br from-slate-100 via-white to-slate-50' + ? 'bg-gradient-to-br from-background via-muted/30 to-background' + : 'bg-gradient-to-br from-card via-accent/10 to-card' if (variant === 'wordmark') { return (
- + EMBERLY
@@ -387,8 +368,8 @@ function AssetPreview({ variant, theme }: AssetPreviewProps) { if (variant === 'mono') { return (
-
- Emberly monochrome +
+ Emberly monochrome
) @@ -399,10 +380,133 @@ function AssetPreview({ variant, theme }: AssetPreviewProps) {
Emberly logo - + Emberly
) } + +// Helper to convert HSL CSS variable to hex +function hslToHex(h: number, s: number, l: number): string { + s /= 100 + l /= 100 + const a = s * Math.min(l, 1 - l) + const f = (n: number) => { + const k = (n + h / 30) % 12 + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1) + return Math.round(255 * color).toString(16).padStart(2, '0') + } + return `#${f(0)}${f(8)}${f(4)}`.toUpperCase() +} + +// Helper to get luminance for contrast calculation +function getLuminance(hex: string): number { + const rgb = hex.replace('#', '').match(/.{2}/g)?.map(x => parseInt(x, 16) / 255) || [0, 0, 0] + const [r, g, b] = rgb.map(c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)) + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +function ThemeColorPalette() { + const [colors, setColors] = useState<{ name: string; hex: string; rgb: string; usage: string }[]>([]) + const [, forceUpdate] = useState(0) + + useEffect(() => { + const updateColors = () => { + const root = document.documentElement + const computed = getComputedStyle(root) + + const resolved = THEME_COLORS.map(color => { + const raw = computed.getPropertyValue(color.cssVar).trim() + // HSL format: "210 40% 98%" or similar + const parts = raw.split(/\s+/).map(p => parseFloat(p)) + let hex = '#000000' + let rgb = '0, 0, 0' + + if (parts.length >= 3) { + hex = hslToHex(parts[0], parts[1], parts[2]) + // Convert hex to RGB + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + rgb = `${r}, ${g}, ${b}` + } + + return { + name: color.name, + hex, + rgb, + usage: color.usage, + } + }) + + setColors(resolved) + } + + // Initial update + updateColors() + + // Watch for theme changes via class or attribute changes on html element + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.attributeName === 'class' || mutation.attributeName === 'style' || mutation.attributeName === 'data-theme') { + // Small delay to let CSS variables update + setTimeout(updateColors, 50) + break + } + } + }) + + observer.observe(document.documentElement, { attributes: true }) + + return () => observer.disconnect() + }, []) + + if (colors.length === 0) { + return ( +
+ {THEME_COLORS.map(color => ( +
+
+
+
+
+
+
+ ))} +
+ ) + } + + return ( +
+ {colors.map(color => { + const isDark = getLuminance(color.hex) < 0.5 + return ( +
+
+ + {color.hex} + +
+
+
{color.name}
+
RGB: {color.rgb}
+
{color.usage}
+
+
+ ) + })} +
+ ) +} diff --git a/app/(main)/press/page.tsx b/app/(main)/press/page.tsx index 792bebc..a8edfc6 100644 --- a/app/(main)/press/page.tsx +++ b/app/(main)/press/page.tsx @@ -48,8 +48,8 @@ const PRESS_RESOURCES = [ description: 'How to properly represent Emberly in articles, presentations, and media.', href: '/press/media-kit#guidelines', label: 'View guidelines', - color: 'text-blue-500', - bg: 'bg-blue-500/10', + color: 'text-accent-foreground', + bg: 'bg-accent/50', }, { icon: Palette, @@ -58,8 +58,8 @@ const PRESS_RESOURCES = [ href: 'https://github.com/EmberlyOSS/Website/releases/latest', label: 'Download ZIP', external: true, - color: 'text-purple-500', - bg: 'bg-purple-500/10', + color: 'text-muted-foreground', + bg: 'bg-muted/50', }, ] @@ -288,7 +288,7 @@ export default function PressPage() {

-
+
Emberly Logo
-
+
Emberly Logo Light diff --git a/app/(main)/status/client.tsx b/app/(main)/status/client.tsx new file mode 100644 index 0000000..46db2d5 --- /dev/null +++ b/app/(main)/status/client.tsx @@ -0,0 +1,128 @@ +'use client' + +import { useState, useCallback } from 'react' + +import { Activity, Server, Wrench } from 'lucide-react' + +import { + StatusHeader, + ComponentsList, + ActiveIncidentsPanel, + ActiveMaintenancesPanel, + IncidentHistory, + MaintenanceHistory, + UptimeDisplay, +} from '@/packages/components/status' +import { + Tabs, + TabsList, + TabsTrigger, + TabsContent, +} from '@/packages/components/ui/tabs' +import { EmberlyStatusResponse } from '@/packages/types/instatus' + +interface StatusPageClientProps { + initialData: EmberlyStatusResponse +} + +export default function StatusPageClient({ + initialData, +}: StatusPageClientProps) { + const [data, setData] = useState(initialData) + const [isRefreshing, setIsRefreshing] = useState(false) + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true) + try { + const res = await fetch('/api/status?full=true') + if (res.ok) { + const json = await res.json() + if (json.success && json.data) { + setData(json.data) + } + } + } catch (error) { + console.error('Failed to refresh status:', error) + } finally { + setIsRefreshing(false) + } + }, []) + + // Count incidents and maintenances for badges + const incidentCount = data.incidents?.length ?? 0 + const maintenanceCount = data.maintenances?.length ?? 0 + + return ( +
+ {/* Overall Status Header */} + + + {/* Active Issues */} + + + + {/* Uptime Stats */} +
+ + + +
+ + {/* Tabbed Content: Components, Incidents, Maintenances */} + + + + + Components + + + + Incidents + {incidentCount > 0 && ( + + {incidentCount} + + )} + + + + Maintenances + {maintenanceCount > 0 && ( + + {maintenanceCount} + + )} + + + + + + + + + + + + + + + +
+ ) +} diff --git a/app/(main)/status/page.tsx b/app/(main)/status/page.tsx new file mode 100644 index 0000000..b6f41e1 --- /dev/null +++ b/app/(main)/status/page.tsx @@ -0,0 +1,184 @@ +import { Suspense } from 'react' +import Link from 'next/link' + +import { Bell, ExternalLink, Shield } from 'lucide-react' + +import { Button } from '@/packages/components/ui/button' +import { Badge } from '@/packages/components/ui/badge' +import HomeShell from '@/packages/components/layout/home-shell' +import { buildPageMetadata } from '@/packages/lib/embeds/metadata' +import { getFullStatusData } from '@/packages/lib/instatus' + +import StatusPageClient from './client' + +export const metadata = buildPageMetadata({ + title: 'System Status', + description: + 'Real time system status and incident history for the Emberly services.', +}) + +// Revalidate every 60 seconds +export const revalidate = 60 + +// Reusable GlassCard component (consistent with other pages) +function GlassCard({ + children, + className = '', +}: { + children: React.ReactNode + className?: string +}) { + return ( +
+
+
{children}
+
+ ) +} + +async function StatusContent() { + const data = await getFullStatusData() + + if (!data) { + return ( + +
+ +

+ Unable to fetch status +

+

+ Please check our Instatus page directly for current + status. +

+ +
+
+ ) + } + + return +} + +export default function StatusPage() { + return ( + +
+ {/* Page Header */} +
+
+ + + System Status + +

+ Service Status +

+

+ Real time status and incident history for all + the Emberly services. Subscribe to updates or check our + Instatus page for detailed information. +

+
+
+ + +
+
+ + {/* Status Content */} + + +
+
+
+
+
+
+
+
+
+ + +
+
+
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+
+ +
+ } + > + + + + {/* Footer Info */} + +
+
+
+

+ Need to report an issue? +

+

+ If you're experiencing problems not shown + here, please let us know. +

+
+
+ + +
+
+
+
+
+ + ) +} diff --git a/app/api/admin/themes/save/route.ts b/app/api/admin/themes/save/route.ts new file mode 100644 index 0000000..c2025b6 --- /dev/null +++ b/app/api/admin/themes/save/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { prisma } from '@/packages/lib/database/prisma' +import { authOptions } from '@/packages/lib/auth' +import { hasPermission, Permission } from '@/packages/lib/permissions' + +/** + * Save a custom system theme that will be available to all users + * Requires MANAGE_APPEARANCE permission (admin/superadmin only) + * + * POST /api/admin/themes/save + * Body: { themeId: string, colors: Record } + */ +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Check permission + if (!hasPermission(session.user.role as any, Permission.MANAGE_APPEARANCE)) { + return NextResponse.json({ error: 'Forbidden: Insufficient permissions' }, { status: 403 }) + } + + const body = await req.json() + const { themeId, colors } = body + + if (!themeId || !colors) { + return NextResponse.json( + { error: 'Missing required fields: themeId, colors' }, + { status: 400 } + ) + } + + if (typeof themeId !== 'string' || typeof colors !== 'object') { + return NextResponse.json( + { error: 'Invalid field types: themeId must be string, colors must be object' }, + { status: 400 } + ) + } + + // Store system themes inside the main site config under key 'site_config' + // so ThemeInitializer/getConfig can pick them up as part of the full config. + let siteConfig = await prisma.config.findUnique({ where: { key: 'site_config' } }) + + const currentValue = (siteConfig?.value as any) || {} + currentValue.settings = currentValue.settings || {} + currentValue.settings.appearance = currentValue.settings.appearance || {} + currentValue.settings.appearance.systemThemes = currentValue.settings.appearance.systemThemes || {} + + // Save/update system theme + currentValue.settings.appearance.systemThemes[themeId] = { + name: themeId, + colors, + createdAt: new Date().toISOString(), + createdBy: session.user.email, + updatedAt: new Date().toISOString(), + } + + if (!siteConfig) { + siteConfig = await prisma.config.create({ + data: { + key: 'site_config', + value: currentValue, + }, + }) + } else { + siteConfig = await prisma.config.update({ + where: { key: 'site_config' }, + data: { value: currentValue }, + }) + } + + return NextResponse.json({ + success: true, + message: `System theme "${themeId}" saved successfully`, + theme: currentValue.settings.appearance.systemThemes[themeId], + }) + } catch (error) { + console.error('[API] Error saving system theme:', error) + return NextResponse.json( + { error: 'Failed to save system theme' }, + { status: 500 } + ) + } +} + +/** + * Get all system themes + * Publicly accessible + * + * GET /api/admin/themes/system + */ +export async function GET() { + try { + const siteConfig = await prisma.config.findUnique({ where: { key: 'site_config' } }) + const systemThemes = (siteConfig?.value as any)?.settings?.appearance?.systemThemes || {} + + return NextResponse.json({ + success: true, + themes: systemThemes, + }) + } catch (error) { + console.error('[API] Error fetching system themes:', error) + return NextResponse.json( + { error: 'Failed to fetch system themes' }, + { status: 500 } + ) + } +} diff --git a/app/api/legal/[id]/route.ts b/app/api/legal/[id]/route.ts index e8a46ff..992bd83 100644 --- a/app/api/legal/[id]/route.ts +++ b/app/api/legal/[id]/route.ts @@ -1,5 +1,6 @@ import { HTTP_STATUS, apiError, apiResponse } from '@/packages/lib/api/response' import { requireAuth } from '@/packages/lib/auth/api-auth' +import { hasPermission, Permission } from '@/packages/lib/permissions' import * as legal from '@/packages/lib/legal/service' export async function GET( @@ -14,7 +15,7 @@ export async function GET( if (adminView) { const { user, response } = await requireAuth(request) if (response) return response - if (!user || (user.role !== 'ADMIN' && user.role !== 'SUPERADMIN')) { + if (!user || !hasPermission(user.role as any, Permission.VIEW_AUDIT_LOGS)) { return apiError('Forbidden', HTTP_STATUS.FORBIDDEN) } const page = await legal.getLegalById(id) @@ -42,7 +43,7 @@ export async function PUT( try { const { user, response } = await requireAuth(request) if (response) return response - if (!user || (user.role !== 'ADMIN' && user.role !== 'SUPERADMIN')) { + if (!user || !hasPermission(user.role as any, Permission.MANAGE_SETTINGS)) { return apiError('Forbidden', HTTP_STATUS.FORBIDDEN) } diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts index 3fde476..cd64114 100644 --- a/app/api/profile/route.ts +++ b/app/api/profile/route.ts @@ -160,6 +160,7 @@ export async function PUT(req: Request) { if (typeof body.enableRichEmbeds === 'boolean') updateData.enableRichEmbeds = body.enableRichEmbeds if (typeof body.theme === 'string') updateData.theme = body.theme + if (body.customColors) updateData.customColors = body.customColors if (body.defaultFileExpiration) updateData.defaultFileExpiration = body.defaultFileExpiration if (body.defaultFileExpirationAction) diff --git a/app/api/setup/route.ts b/app/api/setup/route.ts index 7307e03..76aca04 100644 --- a/app/api/setup/route.ts +++ b/app/api/setup/route.ts @@ -124,7 +124,7 @@ export async function POST(req: Request) { }, }, appearance: { - theme: 'dark', + theme: 'default-dark', favicon: null, customColors: {}, }, diff --git a/app/api/status/components/route.ts b/app/api/status/components/route.ts new file mode 100644 index 0000000..46f8024 --- /dev/null +++ b/app/api/status/components/route.ts @@ -0,0 +1,21 @@ +import { apiResponse, apiError } from '@/packages/lib/api/response' +import { getStatusComponents } from '@/packages/lib/instatus' + +/** + * GET /api/status/components + * Returns all status page components with their current status + */ +export async function GET() { + try { + const components = await getStatusComponents() + + if (!components) { + return apiError('Failed to fetch components', 503) + } + + return apiResponse(components) + } catch (err) { + console.error('Error fetching components:', err) + return apiError('Internal server error', 500) + } +} diff --git a/app/api/status/incidents/route.ts b/app/api/status/incidents/route.ts new file mode 100644 index 0000000..46e525d --- /dev/null +++ b/app/api/status/incidents/route.ts @@ -0,0 +1,16 @@ +import { apiResponse, apiError } from '@/packages/lib/api/response' +import { getIncidents } from '@/packages/lib/instatus' + +/** + * GET /api/status/incidents + * Returns incident history + */ +export async function GET() { + try { + const incidents = await getIncidents() + return apiResponse(incidents) + } catch (err) { + console.error('Error fetching incidents:', err) + return apiError('Internal server error', 500) + } +} diff --git a/app/api/status/maintenances/route.ts b/app/api/status/maintenances/route.ts new file mode 100644 index 0000000..1f56f90 --- /dev/null +++ b/app/api/status/maintenances/route.ts @@ -0,0 +1,16 @@ +import { apiResponse, apiError } from '@/packages/lib/api/response' +import { getMaintenances } from '@/packages/lib/instatus' + +/** + * GET /api/status/maintenances + * Returns scheduled and past maintenances + */ +export async function GET() { + try { + const maintenances = await getMaintenances() + return apiResponse(maintenances) + } catch (err) { + console.error('Error fetching maintenances:', err) + return apiError('Internal server error', 500) + } +} diff --git a/app/api/status/route.ts b/app/api/status/route.ts index a13011a..0fa65a6 100644 --- a/app/api/status/route.ts +++ b/app/api/status/route.ts @@ -1,20 +1,35 @@ import { NextResponse } from 'next/server' -export async function GET() { +import { apiResponse, apiError } from '@/packages/lib/api/response' +import { getFullStatusData, getStatusSummary } from '@/packages/lib/instatus' + +/** + * GET /api/status + * Returns aggregated status data including summary, components, and active issues + */ +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const full = searchParams.get('full') === 'true' + try { - const res = await fetch('https://status.emberly.site/summary.json', { - // prevent Vercel/Next caching - cache: 'no-store', - }) + if (full) { + // Return full aggregated status data + const data = await getFullStatusData() + if (!data) { + return apiError('Failed to fetch status data', 503) + } + return apiResponse(data) + } - if (!res.ok) { - return NextResponse.json({ error: 'Failed to fetch upstream' }, { status: 500 }) + // Return just the summary for quick status checks + const summary = await getStatusSummary() + if (!summary) { + return apiError('Failed to fetch status summary', 503) } - const data = await res.json() - return NextResponse.json(data) + return apiResponse(summary) } catch (err) { console.error('Error fetching status:', err) - return NextResponse.json({ error: 'Internal' }, { status: 500 }) + return apiError('Internal server error', 500) } } diff --git a/app/globals.css b/app/globals.css index cc43fff..e058972 100644 --- a/app/globals.css +++ b/app/globals.css @@ -92,3 +92,66 @@ body { scrollbar-width: thin; scrollbar-color: hsl(var(--muted-foreground)) transparent; } + +/* Theme: The Upside Down - Flips headers and adds eerie effects */ +[data-theme="upside-down"] h1, +[data-theme="upside-down"] h2, +[data-theme="upside-down"] nav, +[data-theme="upside-down"] .upside-down-target { + transform: scaleY(-1); +} + +[data-theme="upside-down"] { + /* Eerie vignette overlay */ + --upside-down-active: 1; +} + +[data-theme="upside-down"]::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9999; + background: radial-gradient(ellipse at center, transparent 40%, rgba(30, 0, 40, 0.4) 100%); + opacity: 0.6; +} + +/* Floating particles effect for upside-down */ +[data-theme="upside-down"] #theme-effects-root::after { + content: ''; + position: absolute; + inset: 0; + background-image: + radial-gradient(2px 2px at 20% 30%, rgba(255, 100, 100, 0.3), transparent), + radial-gradient(2px 2px at 40% 70%, rgba(100, 100, 255, 0.2), transparent), + radial-gradient(1px 1px at 70% 40%, rgba(255, 255, 255, 0.2), transparent), + radial-gradient(2px 2px at 90% 80%, rgba(200, 100, 150, 0.2), transparent); + animation: upside-down-particles 20s linear infinite; +} + +@keyframes upside-down-particles { + 0%, 100% { + transform: translateY(0) rotate(0deg); + opacity: 0.5; + } + 50% { + transform: translateY(-20px) rotate(180deg); + opacity: 0.8; + } +} + +/* Theme effects disabled state */ +[data-effects-disabled="true"] #theme-effects-root { + display: none !important; +} + +[data-effects-disabled="true"][data-theme="upside-down"] h1, +[data-effects-disabled="true"][data-theme="upside-down"] h2, +[data-effects-disabled="true"][data-theme="upside-down"] nav, +[data-effects-disabled="true"][data-theme="upside-down"] .upside-down-target { + transform: none; +} + +[data-effects-disabled="true"][data-theme="upside-down"]::before { + display: none; +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 87f277c..8410a5f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,9 +4,14 @@ import localFont from 'next/font/local' import { CustomHead } from '@/packages/components/layout/custom-head' import { AuthProvider } from '@/packages/components/providers/auth-provider' import { QueryProvider } from '@/packages/components/providers/query-provider' +import { ThemeProviderWrapper } from '@/packages/components/providers/theme-provider-wrapper' import { SetupChecker } from '@/packages/components/setup-checker' import { ThemeInitializer } from '@/packages/components/theme/theme-initializer' import { ThemeProvider } from '@/packages/components/theme/theme-provider' +import { ThemeEffectsWrapper } from '@/packages/components/theme/theme-effects-wrapper' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/packages/lib/auth' +import { prisma } from '@/packages/lib/database/prisma' import { Toaster } from '@/packages/components/ui/toaster' import Snowfall from '@/packages/components/theme/snowfall' @@ -48,8 +53,7 @@ export default async function RootLayout({ children: React.ReactNode }) { const config = await getConfig() - const hasCustomFont = - config.settings.advanced.customCSS.includes('font-family') + const hasCustomFont = config.settings.advanced.customCSS.includes('font-family') if (config.settings.appearance.favicon) { metadata.icons = { @@ -60,37 +64,59 @@ export default async function RootLayout({ } } + // If user is authenticated, fetch their theme so we can server-render it + const session = await getServerSession(authOptions) + let userTheme: string | null = null + let userCustomColors: Record | null = null + + if (session?.user?.id) { + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { theme: true, customColors: true }, + }) + userTheme = user?.theme ?? null + userCustomColors = (user?.customColors as Record) ?? null + } + return ( - + - + + - + - - - - -
{children}
-
-
-
-
- -
+ + +
+ + + + +
{children}
+
+
+
+
+
+ +
+ ) -} +} \ No newline at end of file diff --git a/packages/components/admin/settings/settings-manager.tsx b/packages/components/admin/settings/settings-manager.tsx index ebbd644..f377bd8 100644 --- a/packages/components/admin/settings/settings-manager.tsx +++ b/packages/components/admin/settings/settings-manager.tsx @@ -22,7 +22,7 @@ import { } from 'lucide-react' import { Icons } from '@/components/shared/icons' -import { ThemeCustomizer } from '@/components/theme/theme-customizer' +import { AppearancePanel } from '@/components/appearance/appearance-panel' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { @@ -422,6 +422,14 @@ export function SettingsManager() { }) } + const handleThemePresetChange = (themeId: string, backgroundEffect: string, animationSpeed: string) => { + handleSettingChange('appearance', { + theme: themeId, + backgroundEffect, + animationSpeed, + }) + } + const checkForUpdates = async () => { try { setIsCheckingUpdate(true) @@ -1069,9 +1077,22 @@ export function SettingsManager() { ) && } - { + const res = await fetch('/api/admin/themes/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ themeId, colors }), + }) + if (res.ok) { + const data = await res.json() + toast({ title: 'Success', description: data.message }) + return true + } else { + toast({ title: 'Error', description: 'Failed to save system theme', variant: 'destructive' }) + return false + } + }} /> diff --git a/packages/components/appearance/appearance-panel.tsx b/packages/components/appearance/appearance-panel.tsx new file mode 100644 index 0000000..16ecf5a --- /dev/null +++ b/packages/components/appearance/appearance-panel.tsx @@ -0,0 +1,360 @@ +'use client' + +import { useCallback, useMemo } from 'react' +import { useSession } from 'next-auth/react' +import { useRouter } from 'next/navigation' +import { Check, Sparkles, Zap, RotateCcw } from 'lucide-react' +import { Card } from '@/packages/components/ui/card' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/packages/components/ui/tabs' +import { Button } from '@/packages/components/ui/button' +import { Switch } from '@/packages/components/ui/switch' +import { Label } from '@/packages/components/ui/label' +import { PRESET_HUES, THEME_PRESETS } from '@/packages/components/theme/theme-customizer' +import { sortCategories, getCategoryLabel, getCategoryIcon } from '@/packages/lib/theme/theme-categories' +import { useTheme } from '@/packages/lib/theme/theme-context' +import { hasPermission, Permission } from '@/packages/lib/permissions' +import { useToast } from '@/packages/hooks/use-toast' +import { cn } from '@/packages/lib/utils' + +interface AppearancePanelProps { + /** Whether the panel is in admin mode (can save system themes) */ + isAdminMode?: boolean +} + +/** + * Unified Appearance Panel using the new theme context + * + * Features: + * - Instant preview with automatic rollback on cancel + * - Single source of truth via context + * - Works for both user and admin flows + * - Effects toggle integrated + */ +export function AppearancePanel({ isAdminMode = false }: AppearancePanelProps) { + const { data: session } = useSession() + const router = useRouter() + const { toast } = useToast() + const { + themeId, + effectsEnabled, + isPreview, + previewTheme, + previewHue, + saveTheme, + saveAsSystemTheme, + cancelPreview, + setEffectsEnabled, + resetToDefault, + metadata, + } = useTheme() + + // Check if user has admin permissions + const canManageSystemThemes = useMemo(() => { + if (!isAdminMode) return false + return session?.user?.role && hasPermission(session.user.role as any, Permission.MANAGE_APPEARANCE) + }, [session?.user?.role, isAdminMode]) + + // Group themes by category + const themesByCategory = useMemo(() => { + return THEME_PRESETS.reduce((acc, preset) => { + const category = preset.category || 'basic' + if (!acc[category]) { + acc[category] = [] + } + acc[category].push(preset) + return acc + }, {} as Record) + }, []) + + // Handle preset selection + const handlePresetSelect = useCallback((preset: typeof THEME_PRESETS[0]) => { + const presetThemeId = preset.themeId || preset.name.replace(/[^\w-]/g, '').toLowerCase() + previewTheme(presetThemeId, preset.colors as unknown as Record) + }, [previewTheme]) + + // Handle hue selection + const handleHueSelect = useCallback((hue: number) => { + previewHue(hue) + }, [previewHue]) + + // Handle cancel preview + const handleCancelPreview = useCallback(() => { + cancelPreview() + toast({ + title: 'Preview cancelled', + description: 'Reverted to your saved theme', + }) + }, [cancelPreview, toast]) + + // Handle reset to default + const handleResetToDefault = useCallback(() => { + resetToDefault() + toast({ + title: 'Theme reset', + description: 'Switched to system default theme', + }) + }, [resetToDefault, toast]) + + // Handle effects toggle + const handleEffectsToggle = useCallback((enabled: boolean) => { + setEffectsEnabled(enabled) + toast({ + title: enabled ? 'Effects enabled' : 'Effects disabled', + description: enabled + ? 'Visual effects and animations are now active' + : 'Visual effects have been turned off', + }) + }, [setEffectsEnabled, toast]) + + // Handle save + const handleSave = useCallback(async () => { + let success = false + + if (canManageSystemThemes) { + success = await saveAsSystemTheme() + } else { + success = await saveTheme() + } + + if (success) { + toast({ + title: canManageSystemThemes ? 'System theme updated' : 'Theme saved', + description: metadata?.name + ? `${metadata.name} is now ${canManageSystemThemes ? 'the system default' : 'your active theme'}` + : 'Your appearance settings have been saved', + }) + // Refresh to pick up server-rendered theme + router.refresh() + } else { + toast({ + title: 'Failed to save theme', + description: 'Please try again or check your connection', + variant: 'destructive', + }) + } + }, [canManageSystemThemes, saveAsSystemTheme, saveTheme, router, toast, metadata]) + + // Get the theme ID for a preset (for selection matching) + const getPresetThemeId = (preset: typeof THEME_PRESETS[0]) => { + return preset.themeId || preset.name.replace(/[^\w-]/g, '').toLowerCase() + } + + return ( +
+ {/* Header */} +
+
+

+ + {canManageSystemThemes ? 'System Themes' : 'Appearance'} +

+

+ {canManageSystemThemes + ? 'Manage system wide theme presets' + : 'Customize your visual experience'} +

+
+
+ {isPreview && ( + + )} + +
+
+ + {/* Current Theme Info */} + {metadata && ( + +
+
{metadata.emoji || '🎨'}
+
+
{metadata.name}
+
{metadata.description}
+
+ {isPreview && ( + + Preview + + )} +
+
+ )} + + {/* Theme Presets */} + + + + {sortCategories(Object.keys(themesByCategory) as any[]).map((category) => ( + + {getCategoryIcon(category)} + {getCategoryLabel(category)} + + ))} + + + {sortCategories(Object.keys(themesByCategory) as any[]).map((category) => ( + +
+ {themesByCategory[category].map((preset) => { + const presetId = getPresetThemeId(preset) + const isSelected = themeId === presetId + + return ( + + ) + })} +
+
+ ))} +
+
+ + {/* Hue Customizer */} + +

Custom Hues

+

+ Quick color adjustments based on a single hue value +

+
+ {PRESET_HUES.map(({ hue, name, saturation, lightness }) => { + const isSelected = themeId === `hue:${hue}` + return ( + + ) + })} +
+
+ + {/* Effects Toggle - Only for non-admin mode */} + {!canManageSystemThemes && ( + +
+
+
+ +
+
+ +

+ Enable visual effects like particles, glitch, and special animations +

+
+
+ +
+
+ )} + + {/* Reset Button */} +
+ +
+ + {/* Info Footer */} +

+ Changes preview instantly • {canManageSystemThemes ? 'System themes apply to all users' : 'Click Save to persist your selection'} +

+
+ ) +} diff --git a/packages/components/dashboard/nav.tsx b/packages/components/dashboard/nav.tsx index b42aba3..f1854d1 100644 --- a/packages/components/dashboard/nav.tsx +++ b/packages/components/dashboard/nav.tsx @@ -328,4 +328,4 @@ export function DashboardNav() {
) -} +} \ No newline at end of file diff --git a/packages/components/layout/base-nav.tsx b/packages/components/layout/base-nav.tsx index 0f81f7c..1e4b2a5 100644 --- a/packages/components/layout/base-nav.tsx +++ b/packages/components/layout/base-nav.tsx @@ -339,4 +339,4 @@ export function BaseNav() { ) } -export default BaseNav +export default BaseNav \ No newline at end of file diff --git a/packages/components/profile/appearance.tsx b/packages/components/profile/appearance.tsx index 5c49933..4820adc 100644 --- a/packages/components/profile/appearance.tsx +++ b/packages/components/profile/appearance.tsx @@ -1,132 +1,9 @@ 'use client' -import { useCallback, useState } from 'react' -import { Card } from '@/packages/components/ui/card' -import { PRESET_HUES, THEME_PRESETS } from '@/packages/components/theme/theme-customizer' -import { useProfile } from '@/packages/hooks/use-profile' +import { AppearancePanel } from '@/packages/components/appearance/appearance-panel' export function ProfileAppearance() { - const { updateProfile } = useProfile() - const [selectedTheme, setSelectedTheme] = useState(null) - - const applyPreset = useCallback((preset: any) => { - // preset is the preset object with { name, colors, description } - const colors = preset.colors || preset - Object.entries(colors).forEach(([key, value]) => { - const cssKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`) - document.documentElement.style.setProperty(`--${cssKey}`, value as string) - }) - // set attribute for global effects (snowfall) - try { - document.documentElement.setAttribute('data-theme', preset.name) - } catch (e) { - // noop - } - setSelectedTheme(preset.name) - }, []) - - const applyHue = useCallback((hue: number) => { - const DEFAULT_COLORS: Record = { - background: '222.2 84% 4.9%', - foreground: '210 40% 98%', - card: '222.2 84% 4.9%', - cardForeground: '210 40% 98%', - popover: '222.2 84% 4.9%', - popoverForeground: '210 40% 98%', - primary: '210 40% 98%', - primaryForeground: '222.2 47.4% 11.2%', - secondary: '217.2 32.6% 17.5%', - secondaryForeground: '210 40% 98%', - muted: '217.2 32.6% 17.5%', - mutedForeground: '215 20.2% 65.1%', - accent: '217.2 32.6% 17.5%', - accentForeground: '210 40% 98%', - destructive: '0 62.8% 30.6%', - destructiveForeground: '210 40% 98%', - border: '217.2 32.6% 17.5%', - input: '217.2 32.6% 17.5%', - ring: '212.7 26.8% 83.9%', - } - - Object.entries(DEFAULT_COLORS).forEach(([key, value]) => { - if (key === 'destructive' || key === 'destructiveForeground') { - const cssKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`) - document.documentElement.style.setProperty(`--${cssKey}`, value) - return - } - const [, s, l] = (value as string).split(' ') - const cssKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`) - document.documentElement.style.setProperty(`--${cssKey}`, `${hue} ${s} ${l}`) - }) - - setSelectedTheme(`hue:${hue}`) - try { - document.documentElement.setAttribute('data-theme', `hue:${hue}`) - } catch (e) { } - }, []) - - const handleSave = useCallback(async () => { - if (!selectedTheme) return - await updateProfile({ theme: selectedTheme }) - }, [selectedTheme, updateProfile]) - - return ( -
- -
Curated themes
-
- {THEME_PRESETS.map((preset) => ( - - ))} -
-
- - -
Quick hues
-
- {PRESET_HUES.map(({ hue, name, saturation, lightness }) => ( - - ))} -
-
- -
-
Changes affect only your current browser session until saved.
-
- -
-
-
- ) + return } -export default ProfileAppearance +export default ProfileAppearance \ No newline at end of file diff --git a/packages/components/providers/theme-provider-wrapper.tsx b/packages/components/providers/theme-provider-wrapper.tsx new file mode 100644 index 0000000..f92f55b --- /dev/null +++ b/packages/components/providers/theme-provider-wrapper.tsx @@ -0,0 +1,67 @@ +'use client' + +import React from 'react' +import { EmberlyThemeProvider, type ThemeProviderProps } from '@/packages/lib/theme/theme-context' + +interface ThemeProviderWrapperProps { + children: React.ReactNode + initialUserTheme?: string | null + initialUserColors?: Record | null + systemTheme?: string + systemColors?: Record +} + +/** + * Client-side wrapper for EmberlyThemeProvider + * Handles the save callbacks via API calls + */ +export function ThemeProviderWrapper({ + children, + initialUserTheme, + initialUserColors, + systemTheme, + systemColors, +}: ThemeProviderWrapperProps) { + // Save user theme via profile API + const handleSaveUserTheme = async (themeId: string, colors: Record): Promise => { + try { + const response = await fetch('/api/profile', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ theme: themeId, customColors: colors }), + }) + return response.ok + } catch (error) { + console.error('[ThemeProviderWrapper] Failed to save user theme:', error) + return false + } + } + + // Save system theme via admin API + const handleSaveSystemTheme = async (themeId: string, colors: Record): Promise => { + try { + const response = await fetch('/api/admin/themes/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ themeId, colors }), + }) + return response.ok + } catch (error) { + console.error('[ThemeProviderWrapper] Failed to save system theme:', error) + return false + } + } + + return ( + + {children} + + ) +} diff --git a/packages/components/status/index.tsx b/packages/components/status/index.tsx new file mode 100644 index 0000000..e46cb06 --- /dev/null +++ b/packages/components/status/index.tsx @@ -0,0 +1,1090 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { format, formatDistanceToNow } from 'date-fns' +import { + Activity, + AlertCircle, + AlertTriangle, + ArrowUpRight, + Calendar, + Check, + CheckCircle2, + ChevronDown, + ChevronRight, + Clock, + ExternalLink, + Loader2, + RefreshCw, + Server, + Shield, + Wrench, + XCircle, +} from 'lucide-react' + +import { Badge } from '@/packages/components/ui/badge' +import { Button } from '@/packages/components/ui/button' +import { cn } from '@/packages/lib/utils' +import { + StatusType, + ComponentStatus, + IncidentStatus, + MaintenanceStatus, + StatusSummary, + StatusComponent, + Incident, + Maintenance, + ActiveIncident, + ActiveMaintenance, + STATUS_COLORS, + STATUS_BG_COLORS, + STATUS_LABELS, + INCIDENT_STATUS_LABELS, + MAINTENANCE_STATUS_LABELS, +} from '@/packages/types/instatus' +import { formatRelativeTime } from '@/packages/lib/instatus' + +// ============================================================================ +// GlassCard Component (consistent with other pages) +// ============================================================================ + +function GlassCard({ + children, + className = '', +}: { + children: React.ReactNode + className?: string +}) { + return ( +
+
+
{children}
+
+ ) +} + +// ============================================================================ +// Status Badge Component +// ============================================================================ + +interface StatusBadgeProps { + status: StatusType | ComponentStatus + size?: 'sm' | 'md' | 'lg' + showLabel?: boolean + pulse?: boolean +} + +export function StatusBadge({ + status, + size = 'md', + showLabel = true, + pulse = false, +}: StatusBadgeProps) { + const colorClass = STATUS_COLORS[status] || 'text-muted-foreground' + const bgClass = STATUS_BG_COLORS[status] || 'bg-muted/50' + const label = STATUS_LABELS[status] || status + + const sizeClasses = { + sm: 'text-xs px-2 py-0.5', + md: 'text-sm px-3 py-1', + lg: 'text-base px-4 py-1.5', + } + + const dotSizes = { + sm: 'w-1.5 h-1.5', + md: 'w-2 h-2', + lg: 'w-2.5 h-2.5', + } + + return ( + + + {showLabel && label} + + ) +} + +// ============================================================================ +// Status Icon Component +// ============================================================================ + +interface StatusIconProps { + status: StatusType | ComponentStatus + className?: string +} + +export function StatusIcon({ status, className }: StatusIconProps) { + const Icon = + status === 'UP' || status === 'OPERATIONAL' + ? CheckCircle2 + : status === 'DOWN' || status === 'MAJOROUTAGE' + ? XCircle + : status === 'DEGRADED' || + status === 'DEGRADEDPERFORMANCE' || + status === 'PARTIALOUTAGE' + ? AlertTriangle + : status === 'UNDERMAINTENANCE' + ? Wrench + : AlertCircle + + const colorClass = STATUS_COLORS[status] || 'text-muted-foreground' + + return +} + +// ============================================================================ +// Overall Status Header +// ============================================================================ + +interface StatusHeaderProps { + summary: StatusSummary + lastUpdated?: string + onRefresh?: () => void + isRefreshing?: boolean +} + +export function StatusHeader({ + summary, + lastUpdated, + onRefresh, + isRefreshing, +}: StatusHeaderProps) { + const status = summary?.page?.status ?? 'UNKNOWN' + const activeIncidents = summary?.activeIncidents ?? [] + const activeMaintenances = summary?.activeMaintenances ?? [] + const hasActiveIssues = + activeIncidents.length > 0 || activeMaintenances.length > 0 + + const statusMessages: Record = { + UP: 'All systems operational', + DOWN: 'Major outage in progress', + DEGRADED: 'Some systems experiencing issues', + UNKNOWN: 'Unable to determine status', + } + + return ( + +
+
+
+
+ +
+
+

+ {statusMessages[status] || 'Status Unknown'} +

+
+ + {lastUpdated && ( + + Updated{' '} + {formatDistanceToNow( + new Date(lastUpdated), + { addSuffix: true } + )} + + )} +
+
+
+ +
+ {onRefresh && ( + + )} + +
+
+ + {/* Active Issues Alert */} + {hasActiveIssues && ( +
+
+ +
+

+ Active Issues +

+

+ {activeIncidents.length > 0 && + `${activeIncidents.length} incident${activeIncidents.length > 1 ? 's' : ''}`} + {activeIncidents.length > 0 && + activeMaintenances.length > 0 && + ' and '} + {activeMaintenances.length > 0 && + `${activeMaintenances.length} maintenance${activeMaintenances.length > 1 ? 's' : ''}`} + {' in progress'} +

+
+
+
+ )} +
+
+ ) +} + +// ============================================================================ +// Components List +// ============================================================================ + +interface ComponentsListProps { + components: StatusComponent[] +} + +export function ComponentsList({ components }: ComponentsListProps) { + const [expandedGroups, setExpandedGroups] = useState>( + new Set() + ) + + // Ensure components is an array + const componentsList = Array.isArray(components) ? components : [] + + // Build a map of parent IDs to their children from group references + // The Instatus API returns a flat list where children have group.id pointing to parent + const childrenByParent = new Map() + const parentIds = new Set() + + componentsList.forEach(component => { + if (component.group?.id) { + parentIds.add(component.group.id) + const existing = childrenByParent.get(component.group.id) || [] + existing.push(component) + childrenByParent.set(component.group.id, existing) + } + }) + + // Parent components are those that have children referencing them + // OR those explicitly marked as isParent (for backwards compatibility) + const parentComponents = componentsList + .filter(c => c.isParent || parentIds.has(c.id)) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + + // Standalone components have no group and aren't parents + const standaloneComponents = componentsList + .filter(c => !c.group && !c.isParent && !parentIds.has(c.id)) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + + // Helper to get children for a parent + const getChildrenForParent = (parent: StatusComponent): StatusComponent[] => { + const nestedChildren = Array.isArray(parent.children) ? parent.children : [] + const groupedChildren = childrenByParent.get(parent.id) || [] + + // Combine both sources, avoiding duplicates by ID + const allChildren = [...nestedChildren] + const existingIds = new Set(nestedChildren.map(c => c.id)) + + groupedChildren.forEach(child => { + if (!existingIds.has(child.id)) { + allChildren.push(child) + } + }) + + // Sort by order if available + return allChildren.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + } + + const toggleGroup = (id: string) => { + setExpandedGroups(prev => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + + return ( + +
+
+ +

Components

+
+ +
+ {/* Standalone components */} + {standaloneComponents.map(component => ( + + ))} + + {/* Parent components with children */} + {parentComponents.map(parent => { + const isExpanded = expandedGroups.has(parent.id) + const children = getChildrenForParent(parent) + + return ( +
+ + + {/* Children */} + {isExpanded && children.length > 0 && ( +
+ {children.map(child => ( + + ))} +
+ )} +
+ ) + })} + + {componentsList.length === 0 && ( +
+ +

No components found

+
+ )} +
+
+
+ ) +} + +function ComponentRow({ + component, + isChild, +}: { + component: StatusComponent + isChild?: boolean +}) { + return ( +
+
+ + + {component.name} + + {component.description && ( + + {component.description} + + )} +
+ +
+ ) +} + +// ============================================================================ +// Active Incidents Panel +// ============================================================================ + +interface ActiveIncidentsPanelProps { + incidents: ActiveIncident[] +} + +export function ActiveIncidentsPanel({ + incidents, +}: ActiveIncidentsPanelProps) { + const incidentsList = Array.isArray(incidents) ? incidents : [] + if (incidentsList.length === 0) return null + + return ( + +
+
+ +

Active Incidents

+
+ +
+ {incidentsList.map(incident => ( + +
+
+

+ {incident.name} +

+
+ + {INCIDENT_STATUS_LABELS[ + incident.status + ] || incident.status} + + + Started{' '} + {formatRelativeTime( + incident.started + )} + +
+
+ +
+ + ))} +
+
+
+ ) +} + +// ============================================================================ +// Active Maintenances Panel +// ============================================================================ + +interface ActiveMaintenancesPanelProps { + maintenances: ActiveMaintenance[] +} + +export function ActiveMaintenancesPanel({ + maintenances, +}: ActiveMaintenancesPanelProps) { + const maintenancesList = Array.isArray(maintenances) ? maintenances : [] + if (maintenancesList.length === 0) return null + + return ( + +
+
+ +

+ Scheduled Maintenance +

+
+ +
+ {maintenancesList.map(maintenance => ( + +
+
+

+ {maintenance.name} +

+
+ + {MAINTENANCE_STATUS_LABELS[ + maintenance.status + ] || maintenance.status} + + + + {format( + new Date(maintenance.start), + 'MMM d, yyyy h:mm a' + )} + + {maintenance.duration && ( + + + {maintenance.duration} min + + )} +
+
+ +
+ + ))} +
+
+
+ ) +} + +// ============================================================================ +// Incident History +// ============================================================================ + +interface IncidentHistoryProps { + incidents: Incident[] + limit?: number +} + +export function IncidentHistory({ + incidents, + limit = 10, +}: IncidentHistoryProps) { + const [expanded, setExpanded] = useState>(new Set()) + const incidentsList = Array.isArray(incidents) ? incidents : [] + const displayIncidents = incidentsList.slice(0, limit) + + const toggleIncident = (id: string) => { + setExpanded(prev => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + + return ( + +
+
+ +

Incident History

+
+ + {displayIncidents.length === 0 ? ( +
+ +

No recent incidents

+

+ All systems have been running smoothly +

+
+ ) : ( +
+ {displayIncidents.map(incident => { + const isExpanded = expanded.has(incident.id) + const isResolved = incident.status === 'RESOLVED' + + return ( +
+ + + {/* Updates Timeline */} + {isExpanded && + incident.updates.length > 0 && ( +
+
+ {incident.updates.map( + update => ( +
+
+
+
+ + {INCIDENT_STATUS_LABELS[ + update + .status + ] || + update.status} + + + {format( + new Date( + update.createdAt + ), + 'MMM d, h:mm a' + )} + +
+
+
+
+ ) + )} +
+
+ )} +
+ ) + })} +
+ )} +
+ + ) +} + +// ============================================================================ +// Maintenance History +// ============================================================================ + +interface MaintenanceHistoryProps { + maintenances: Maintenance[] + limit?: number +} + +export function MaintenanceHistory({ + maintenances, + limit = 10, +}: MaintenanceHistoryProps) { + const [expanded, setExpanded] = useState>(new Set()) + const maintenancesList = Array.isArray(maintenances) ? maintenances : [] + const displayMaintenances = maintenancesList.slice(0, limit) + + const toggleMaintenance = (id: string) => { + setExpanded(prev => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + + // Format duration from minutes to human readable + const formatDuration = (minutes: number | null): string => { + if (!minutes) return 'Unknown duration' + if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'}` + const hours = Math.floor(minutes / 60) + const remainingMinutes = minutes % 60 + if (remainingMinutes === 0) return `${hours} hour${hours === 1 ? '' : 's'}` + return `${hours}h ${remainingMinutes}m` + } + + return ( + +
+
+ +

Maintenance History

+
+ + {displayMaintenances.length === 0 ? ( +
+ +

No scheduled maintenances

+

+ No maintenance windows have been scheduled +

+
+ ) : ( +
+ {displayMaintenances.map(maintenance => { + const isExpanded = expanded.has(maintenance.id) + const isCompleted = maintenance.status === 'COMPLETED' + const isInProgress = maintenance.status === 'INPROGRESS' + + return ( +
+ + + {/* Updates Timeline */} + {isExpanded && + maintenance.updates && + maintenance.updates.length > 0 && ( +
+
+ {maintenance.updates.map( + update => ( +
+
+
+
+ + {MAINTENANCE_STATUS_LABELS[ + update + .status + ] || + update.status} + + + {format( + new Date( + update.started + ), + 'MMM d, h:mm a' + )} + +
+
+
+
+ ) + )} +
+
+ )} + + {/* Affected Components */} + {isExpanded && + maintenance.components && + maintenance.components.length > 0 && ( +
+
+ Affected components: +
+
+ {maintenance.components.map( + component => ( + + {component.name} + + ) + )} +
+
+ )} +
+ ) + })} +
+ )} +
+ + ) +} + +// ============================================================================ +// Status Page Loading Skeleton +// ============================================================================ + +export function StatusPageSkeleton() { + return ( +
+ {/* Header Skeleton */} + +
+
+
+
+
+
+
+
+
+ + + {/* Components Skeleton */} + +
+
+
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+
+ +
+ ) +} + +// ============================================================================ +// Uptime Display +// ============================================================================ + +interface UptimeDisplayProps { + uptime: number + days?: number +} + +export function UptimeDisplay({ uptime, days = 90 }: UptimeDisplayProps) { + return ( + +
+
+
+ +

+ Uptime ({days} days) +

+
+ = 99.9 + ? 'text-emerald-500' + : uptime >= 99 + ? 'text-yellow-500' + : 'text-red-500' + )} + > + {uptime.toFixed(2)}% + +
+ + {/* Visual bar */} +
+
= 99.9 + ? 'bg-emerald-500' + : uptime >= 99 + ? 'bg-yellow-500' + : 'bg-red-500' + )} + style={{ width: `${uptime}%` }} + /> +
+
+ + ) +} diff --git a/packages/components/theme/animated-background.tsx b/packages/components/theme/animated-background.tsx new file mode 100644 index 0000000..89496ac --- /dev/null +++ b/packages/components/theme/animated-background.tsx @@ -0,0 +1,529 @@ +'use client' + +import React, { useEffect, useRef } from 'react' + +interface AnimatedBackgroundProps { + type: 'particles' | 'gradient-shift' | 'waves' | 'glitch' | 'grid' | 'parallax' | 'aurora' | 'stars' | 'matrix' | 'scanlines' + intensity?: number + speed?: 'slow' | 'medium' | 'fast' + color?: string +} + +/** + * Animated Background Effects Component + * Provides various visual effects for themed backgrounds + */ +export const AnimatedBackground: React.FC = ({ + type, + intensity = 0.5, + speed = 'medium', + color = '#6366f1', +}) => { + const canvasRef = useRef(null) + const animationRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) { + console.error('[AnimatedBackground] Canvas ref not found') + return + } + + const ctx = canvas.getContext('2d') + if (!ctx) { + console.error('[AnimatedBackground] Could not get 2D context') + return + } + + // Set canvas size to match viewport exactly + const resizeCanvas = () => { + const w = window.innerWidth + const h = window.innerHeight + + // Set canvas internal resolution + canvas.width = w + canvas.height = h + + // Reset context state after resize + ctx.imageSmoothingEnabled = true + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + } + + resizeCanvas() + window.addEventListener('resize', resizeCanvas) + + const speedMultiplier = speed === 'slow' ? 0.5 : speed === 'fast' ? 2 : 1 + + console.log(`[AnimatedBackground] Starting ${type} animation with intensity=${intensity}, speed=${speed}`) + + // Start the appropriate animation + if (type === 'particles') { + animationRef.current = startParticles(ctx, canvas, intensity, speedMultiplier, color) + } else if (type === 'gradient-shift') { + animationRef.current = startGradientShift(ctx, canvas, intensity, speedMultiplier) + } else if (type === 'waves') { + animationRef.current = startWaves(ctx, canvas, intensity, speedMultiplier, color) + } else if (type === 'grid') { + animationRef.current = startGrid(ctx, canvas, intensity, color) + } else if (type === 'aurora') { + animationRef.current = startAurora(ctx, canvas, intensity, speedMultiplier) + } else if (type === 'stars') { + animationRef.current = startStarfield(ctx, canvas, intensity, speedMultiplier) + } else if (type === 'matrix') { + animationRef.current = startMatrix(ctx, canvas, intensity, speedMultiplier) + } else if (type === 'parallax') { + animationRef.current = startParallax(ctx, canvas, intensity, speedMultiplier) + } else if (type === 'glitch') { + animationRef.current = startGlitch(ctx, canvas, intensity, speedMultiplier) + } else if (type === 'scanlines') { + animationRef.current = startScanlines(ctx, canvas, intensity) + } + + return () => { + window.removeEventListener('resize', resizeCanvas) + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + animationRef.current = null + } + } + }, [type, intensity, speed, color]) + + return ( + + ) +} + +// ============ ANIMATION STARTERS ============ + +function startParticles( + ctx: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + intensity: number, + speedMultiplier: number, + color: string +): number { + const particles: Array<{ x: number; y: number; vx: number; vy: number; size: number }> = [] + const particleCount = Math.floor(intensity * 100) + 20 // Minimum 20 particles + + for (let i = 0; i < particleCount; i++) { + particles.push({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height, + vx: (Math.random() - 0.5) * speedMultiplier * 2, + vy: (Math.random() - 0.5) * speedMultiplier * 2, + size: Math.random() * 4 + 1, + }) + } + + const animate = () => { + // Clear with semi-transparent background for motion blur + ctx.fillStyle = 'rgba(15, 23, 42, 0.1)' + ctx.fillRect(0, 0, canvas.width, canvas.height) + + ctx.fillStyle = color + ctx.globalAlpha = Math.min(intensity, 1) + + particles.forEach((p) => { + p.x += p.vx + p.y += p.vy + + if (p.x < 0) p.x = canvas.width + if (p.x > canvas.width) p.x = 0 + if (p.y < 0) p.y = canvas.height + if (p.y > canvas.height) p.y = 0 + + ctx.beginPath() + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2) + ctx.fill() + }) + + ctx.globalAlpha = 1 + return requestAnimationFrame(animate) + } + + return animate() +} + +function startGradientShift( + ctx: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + intensity: number, + speedMultiplier: number +): number { + let hue = 0 + + const animate = () => { + hue += speedMultiplier * 0.3 + hue = hue % 360 + + const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height) + gradient.addColorStop(0, `hsl(${hue}, 100%, 50%)`) + gradient.addColorStop(0.5, `hsl(${(hue + 120) % 360}, 100%, 50%)`) + gradient.addColorStop(1, `hsl(${(hue + 240) % 360}, 100%, 50%)`) + + ctx.fillStyle = gradient + ctx.globalAlpha = Math.min(intensity * 0.8, 0.9) + ctx.fillRect(0, 0, canvas.width, canvas.height) + ctx.globalAlpha = 1 + + return requestAnimationFrame(animate) + } + + return animate() +} + +function startWaves( + ctx: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + intensity: number, + speedMultiplier: number, + color: string +): number { + let time = 0 + + const animate = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.fillStyle = 'rgba(15, 23, 42, 1)' + ctx.fillRect(0, 0, canvas.width, canvas.height) + + ctx.strokeStyle = color + ctx.lineWidth = 2 + ctx.globalAlpha = Math.min(intensity, 0.8) + + for (let waveIndex = 0; waveIndex < 4; waveIndex++) { + ctx.beginPath() + + const waveHeight = 40 * intensity + const waveFrequency = 0.01 + const yOffset = (canvas.height / 5) * (waveIndex + 1) + const phaseShift = waveIndex * 0.5 + + for (let x = 0; x < canvas.width; x += 5) { + const y = + yOffset + + Math.sin((x * waveFrequency + time * speedMultiplier * 0.02 + phaseShift) * Math.PI) * + waveHeight + + if (x === 0) ctx.moveTo(x, y) + else ctx.lineTo(x, y) + } + + ctx.stroke() + } + + ctx.globalAlpha = 1 + time++ + + return requestAnimationFrame(animate) + } + + return animate() +} + +function startGrid( + ctx: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + intensity: number, + color: string +): number { + let offset = 0 + + const animate = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.fillStyle = 'rgba(15, 23, 42, 1)' + ctx.fillRect(0, 0, canvas.width, canvas.height) + + ctx.strokeStyle = color + ctx.globalAlpha = Math.min(intensity, 0.7) + ctx.lineWidth = 1 + + const gridSize = 40 + + // Draw moving grid + for (let x = -gridSize; x < canvas.width + gridSize; x += gridSize) { + ctx.beginPath() + ctx.moveTo(x + offset, 0) + ctx.lineTo(x + offset, canvas.height) + ctx.stroke() + } + + for (let y = -gridSize; y < canvas.height + gridSize; y += gridSize) { + ctx.beginPath() + ctx.moveTo(0, y + offset) + ctx.lineTo(canvas.width, y + offset) + ctx.stroke() + } + + ctx.globalAlpha = 1 + offset = (offset + 1) % gridSize + + return requestAnimationFrame(animate) + } + + return animate() +} + +function startAurora( + ctx: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + intensity: number, + speedMultiplier: number +): number { + let time = 0 + + const animate = () => { + // Fade the canvas background over time for aurora trail effect + ctx.fillStyle = 'rgba(0, 0, 0, 0.08)' + ctx.fillRect(0, 0, canvas.width, canvas.height) + + // Draw multiple flowing aurora bands with wave motion + for (let bandIndex = 0; bandIndex < 4; bandIndex++) { + const baseHue = 220 + bandIndex * 40 + const waveOffset = Math.sin(time * 0.008 * speedMultiplier + bandIndex * 0.5) * 100 + const bandHeight = canvas.height * 0.15 + const bandY = canvas.height * 0.25 + bandIndex * 80 + waveOffset + + // Create horizontal gradient for the aurora band + const gradient = ctx.createLinearGradient(0, bandY - bandHeight / 2, 0, bandY + bandHeight / 2) + + const hue = (baseHue + time * speedMultiplier * 0.3) % 360 + const hue2 = (baseHue + 60 + time * speedMultiplier * 0.25) % 360 + + // Create color stops for smooth aurora effect + gradient.addColorStop(0, `hsla(${hue}, 100%, 40%, 0)`) + gradient.addColorStop(0.25, `hsla(${hue}, 100%, 60%, 0.6)`) + gradient.addColorStop(0.5, `hsla(${hue2}, 100%, 70%, 0.8)`) + gradient.addColorStop(0.75, `hsla(${hue}, 100%, 60%, 0.6)`) + gradient.addColorStop(1, `hsla(${hue}, 100%, 40%, 0)`) + + ctx.fillStyle = gradient + ctx.globalAlpha = Math.min(intensity, 1) + ctx.fillRect(0, bandY - bandHeight / 2, canvas.width, bandHeight) + + // Add a glowing effect with vertical gradients + const glowGradient = ctx.createLinearGradient(0, bandY - bandHeight, 0, bandY + bandHeight) + glowGradient.addColorStop(0.3, `hsla(${hue}, 100%, 50%, 0.2)`) + glowGradient.addColorStop(0.5, `hsla(${hue}, 100%, 50%, 0.4)`) + glowGradient.addColorStop(0.7, `hsla(${hue}, 100%, 50%, 0.2)`) + + ctx.fillStyle = glowGradient + ctx.globalAlpha = Math.min(intensity * 0.5, 0.5) + ctx.fillRect(0, bandY - bandHeight, canvas.width, bandHeight * 2) + } + + ctx.globalAlpha = 1 + time++ + + return requestAnimationFrame(animate) + } + + return animate() +} + +function startStarfield( + ctx: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + intensity: number, + speedMultiplier: number +): number { + const stars: Array<{ x: number; y: number; z: number; vz: number }> = [] + const starCount = Math.floor(intensity * 150) + + for (let i = 0; i < starCount; i++) { + stars.push({ + x: Math.random() * canvas.width - canvas.width / 2, + y: Math.random() * canvas.height - canvas.height / 2, + z: Math.random() * 1000, + vz: Math.random() * 5 * speedMultiplier + speedMultiplier, + }) + } + + const animate = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.fillStyle = 'rgba(0, 0, 0, 1)' + ctx.fillRect(0, 0, canvas.width, canvas.height) + ctx.fillStyle = '#fff' + + stars.forEach((star) => { + star.z -= star.vz + + if (star.z <= 0) { + star.z = 1000 + } + + const x = (star.x / star.z) * 300 + canvas.width / 2 + const y = (star.y / star.z) * 300 + canvas.height / 2 + const size = (1 - star.z / 1000) * 3 + + if (size > 0) { + ctx.globalAlpha = Math.min(1 - star.z / 1000 + 0.3, 1) + ctx.fillRect(x, y, size, size) + } + }) + + ctx.globalAlpha = 1 + return requestAnimationFrame(animate) + } + + return animate() +} + +function startMatrix( + ctx: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + intensity: number, + speedMultiplier: number +): number { + const chars = '01アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン'.split('') + const fontSize = 14 + const columns = Math.floor(canvas.width / fontSize) + const drops: number[] = [] + + for (let i = 0; i < columns; i++) { + drops[i] = Math.floor(Math.random() * canvas.height) + } + + const animate = () => { + ctx.fillStyle = 'rgba(0, 20, 0, 1)' + ctx.fillRect(0, 0, canvas.width, canvas.height) + + ctx.fillStyle = `hsla(120, 100%, 50%, ${Math.min(intensity, 0.8)})` + ctx.font = `${fontSize}px monospace` + + for (let i = 0; i < drops.length; i++) { + const char = chars[Math.floor(Math.random() * chars.length)] + ctx.fillText(char, i * fontSize, drops[i] * fontSize) + + if (drops[i] * fontSize > canvas.height && Math.random() > 0.975) { + drops[i] = 0 + } + + drops[i] += speedMultiplier * 0.5 + } + + return requestAnimationFrame(animate) + } + + return animate() +} + +function startParallax( + ctx: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + intensity: number, + speedMultiplier: number +): number { + let offset = 0 + const layers = [ + { speed: 0.2, color: 'rgba(100, 150, 255, 0.1)', height: 0.3 }, + { speed: 0.5, color: 'rgba(150, 100, 255, 0.15)', height: 0.6 }, + { speed: 1, color: 'rgba(200, 100, 255, 0.2)', height: 1 }, + ] + + const animate = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.fillStyle = 'rgba(15, 23, 42, 1)' + ctx.fillRect(0, 0, canvas.width, canvas.height) + + layers.forEach((layer) => { + ctx.fillStyle = layer.color + ctx.globalAlpha = Math.min(intensity * layer.speed, 0.8) + ctx.fillRect(0, canvas.height * layer.height * 0.5, canvas.width, canvas.height * layer.height * 0.5) + }) + + ctx.globalAlpha = 1 + offset += speedMultiplier * 0.5 + + return requestAnimationFrame(animate) + } + + return animate() +} + +function startGlitch( + ctx: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + intensity: number, + speedMultiplier: number +): number { + let time = 0 + + const animate = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.fillStyle = 'rgba(15, 23, 42, 1)' + ctx.fillRect(0, 0, canvas.width, canvas.height) + + // Random glitch lines + for (let i = 0; i < 5 * intensity; i++) { + if (Math.random() > 0.8 - intensity * 0.2) { + const y = Math.random() * canvas.height + const height = Math.random() * 40 + 5 + const offset = (Math.random() - 0.5) * 30 + + ctx.fillStyle = i % 2 === 0 ? 'rgba(255, 0, 0, 0.5)' : 'rgba(0, 255, 255, 0.5)' + ctx.globalAlpha = Math.min(intensity, 0.8) + ctx.fillRect(offset, y, canvas.width - Math.abs(offset), height) + } + } + + // Glitch blocks + if (time % 10 === 0) { + const numGlitches = Math.floor(intensity * 3) + for (let i = 0; i < numGlitches; i++) { + const x = Math.random() * canvas.width + const y = Math.random() * canvas.height + const w = Math.random() * 100 + 50 + const h = Math.random() * 50 + 20 + + ctx.fillStyle = Math.random() > 0.5 ? 'rgba(255, 0, 0, 0.4)' : 'rgba(0, 255, 255, 0.4)' + ctx.globalAlpha = Math.min(intensity, 0.8) + ctx.fillRect(x, y, w, h) + } + } + + ctx.globalAlpha = 1 + time += speedMultiplier + + return requestAnimationFrame(animate) + } + + return animate() +} +// Scanlines effect - CRT monitor style +function startScanlines( + ctx: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + intensity: number +): number { + const animate = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height) + + // Draw scanlines + ctx.fillStyle = `rgba(0, 0, 0, ${0.15 * intensity})` + for (let y = 0; y < canvas.height; y += 2) { + ctx.fillRect(0, y, canvas.width, 1) + } + + // Optional: Add subtle flicker + if (Math.random() > 0.97) { + ctx.fillStyle = `rgba(255, 255, 255, ${0.02 * intensity})` + ctx.fillRect(0, 0, canvas.width, canvas.height) + } + + return requestAnimationFrame(animate) + } + + return animate() +} \ No newline at end of file diff --git a/packages/components/theme/gaming-background.tsx b/packages/components/theme/gaming-background.tsx new file mode 100644 index 0000000..8143998 --- /dev/null +++ b/packages/components/theme/gaming-background.tsx @@ -0,0 +1,167 @@ +'use client' + +import React, { useEffect, useRef } from 'react' + +interface GamingBackgroundProps { + theme: + | 'retro-arcade' + | 'cyberpunk-neon' + | 'vaporwave' + | 'dark-matrix' + | 'neon-grid' + | 'cosmic-space' + intensity?: number +} + +/** + * Gaming-specific theme background component + * Provides CRT scanlines, curvature, chroma aberration, and vignette effects + */ +export const GamingBackground: React.FC = ({ + theme, + intensity = 0.8, +}) => { + const containerRef = useRef(null) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + // Apply theme-specific styles + const style = container.style + + switch (theme) { + case 'retro-arcade': + style.filter = ` + brightness(1.1) + saturate(1.2) + drop-shadow(0 0 20px rgba(255, 0, 127, 0.3)) + ` + // Add scanlines overlay + container.classList.add('scanlines-overlay') + break + + case 'cyberpunk-neon': + style.filter = ` + hue-rotate(10deg) + saturate(1.5) + contrast(1.2) + drop-shadow(0 0 30px rgba(0, 255, 255, 0.4)) + ` + container.classList.add('chromatic-aberration') + break + + case 'vaporwave': + style.filter = ` + hue-rotate(-30deg) + saturate(1.3) + brightness(0.95) + ` + break + + case 'dark-matrix': + style.filter = ` + hue-rotate(120deg) + saturate(0.8) + contrast(1.1) + brightness(0.9) + drop-shadow(0 0 20px rgba(0, 255, 0, 0.2)) + ` + container.classList.add('scanlines-overlay') + break + + case 'neon-grid': + style.filter = ` + contrast(1.3) + saturate(1.4) + drop-shadow(0 0 25px rgba(0, 200, 255, 0.3)) + ` + break + + case 'cosmic-space': + style.filter = ` + brightness(0.9) + contrast(1.15) + drop-shadow(0 0 40px rgba(138, 43, 226, 0.2)) + ` + break + } + + return () => { + container.classList.remove('scanlines-overlay', 'chromatic-aberration') + } + }, [theme]) + + return ( +
+ {/* Vignette effect */} +
+ + {/* Optional CRT curve effect using CSS */} + +
+ ) +} diff --git a/packages/components/theme/theme-customizer.tsx b/packages/components/theme/theme-customizer.tsx index ec0a082..6857f62 100644 --- a/packages/components/theme/theme-customizer.tsx +++ b/packages/components/theme/theme-customizer.tsx @@ -12,6 +12,8 @@ import { } from '@/packages/components/ui/collapsible' import { Input } from '@/packages/components/ui/input' import { Label } from '@/packages/components/ui/label' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/packages/components/ui/tabs' +import { sortCategories, getCategoryLabel, getCategoryIcon } from '@/packages/lib/theme/theme-categories' interface ColorConfig { background: string @@ -37,6 +39,7 @@ interface ColorConfig { interface ThemeCustomizerProps { onColorChange: (colors: Partial) => void + onThemePresetChange?: (themeId: string, backgroundEffect: string, animationSpeed: string) => void initialColors?: Partial } @@ -152,6 +155,28 @@ const STRANGER_THINGS_THEME: ColorConfig = { ring: '354 82% 56%', } +const UPSIDE_DOWN_THEME: ColorConfig = { + background: '270 30% 4%', + foreground: '280 15% 85%', + card: '270 28% 6%', + cardForeground: '280 15% 85%', + popover: '270 28% 6%', + popoverForeground: '280 15% 85%', + primary: '354 70% 45%', + primaryForeground: '280 15% 95%', + secondary: '270 25% 12%', + secondaryForeground: '280 15% 85%', + muted: '270 20% 18%', + mutedForeground: '280 10% 60%', + accent: '200 60% 35%', + accentForeground: '280 15% 95%', + destructive: '0 55% 40%', + destructiveForeground: '280 15% 95%', + border: '270 20% 14%', + input: '270 20% 14%', + ring: '354 70% 50%', +} + const CHRISTMAS_THEME: ColorConfig = { background: '140 40% 6%', foreground: '210 40% 98%', @@ -240,40 +265,298 @@ const REMEMBRANCE_THEME: ColorConfig = { ring: '345 82% 56%', } +const RETRO_ARCADE_THEME: ColorConfig = { + background: '280 25% 8%', + foreground: '210 40% 98%', + card: '280 30% 10%', + cardForeground: '210 40% 98%', + popover: '280 30% 10%', + popoverForeground: '210 40% 98%', + primary: '300 100% 50%', + primaryForeground: '280 25% 8%', + secondary: '45 100% 50%', + secondaryForeground: '280 25% 8%', + muted: '280 20% 20%', + mutedForeground: '215 20% 65.1%', + accent: '0 100% 50%', + accentForeground: '280 25% 8%', + destructive: '0 100% 50%', + destructiveForeground: '210 40% 98%', + border: '280 20% 16%', + input: '280 20% 16%', + ring: '300 100% 60%', +} + +const CYBERPUNK_NEON_THEME: ColorConfig = { + background: '270 20% 5%', + foreground: '210 40% 98%', + card: '270 25% 8%', + cardForeground: '210 40% 98%', + popover: '270 25% 8%', + popoverForeground: '210 40% 98%', + primary: '180 100% 45%', + primaryForeground: '270 20% 5%', + secondary: '300 100% 48%', + secondaryForeground: '270 20% 5%', + muted: '270 20% 18%', + mutedForeground: '215 20% 65.1%', + accent: '0 100% 50%', + accentForeground: '270 20% 5%', + destructive: '0 100% 50%', + destructiveForeground: '210 40% 98%', + border: '270 20% 14%', + input: '270 20% 14%', + ring: '180 100% 55%', +} + +const VAPORWAVE_THEME: ColorConfig = { + background: '300 40% 8%', + foreground: '210 40% 98%', + card: '300 35% 10%', + cardForeground: '210 40% 98%', + popover: '300 35% 10%', + popoverForeground: '210 40% 98%', + primary: '280 80% 50%', + primaryForeground: '300 40% 8%', + secondary: '40 100% 50%', + secondaryForeground: '300 40% 8%', + muted: '300 30% 20%', + mutedForeground: '215 20% 65.1%', + accent: '200 100% 50%', + accentForeground: '300 40% 8%', + destructive: '20 100% 50%', + destructiveForeground: '210 40% 98%', + border: '300 30% 16%', + input: '300 30% 16%', + ring: '280 80% 60%', +} + +const DARK_MATRIX_THEME: ColorConfig = { + background: '120 40% 5%', + foreground: '120 100% 90%', + card: '120 35% 7%', + cardForeground: '120 100% 90%', + popover: '120 35% 7%', + popoverForeground: '120 100% 90%', + primary: '120 100% 50%', + primaryForeground: '120 40% 5%', + secondary: '220 30% 15%', + secondaryForeground: '120 100% 90%', + muted: '120 30% 16%', + mutedForeground: '120 80% 70%', + accent: '60 100% 50%', + accentForeground: '120 40% 5%', + destructive: '0 100% 45%', + destructiveForeground: '120 100% 90%', + border: '120 30% 12%', + input: '120 30% 12%', + ring: '120 100% 60%', +} + +const NEON_GRID_THEME: ColorConfig = { + background: '200 30% 6%', + foreground: '210 40% 98%', + card: '200 35% 8%', + cardForeground: '210 40% 98%', + popover: '200 35% 8%', + popoverForeground: '210 40% 98%', + primary: '180 100% 45%', + primaryForeground: '200 30% 6%', + secondary: '280 100% 50%', + secondaryForeground: '200 30% 6%', + muted: '200 25% 18%', + mutedForeground: '215 20% 65.1%', + accent: '60 100% 50%', + accentForeground: '200 30% 6%', + destructive: '0 100% 50%', + destructiveForeground: '210 40% 98%', + border: '200 25% 14%', + input: '200 25% 14%', + ring: '180 100% 55%', +} + +const COSMIC_SPACE_THEME: ColorConfig = { + background: '260 40% 6%', + foreground: '210 40% 98%', + card: '260 35% 8%', + cardForeground: '210 40% 98%', + popover: '260 35% 8%', + popoverForeground: '210 40% 98%', + primary: '270 100% 50%', + primaryForeground: '260 40% 6%', + secondary: '280 80% 45%', + secondaryForeground: '210 40% 98%', + muted: '260 30% 18%', + mutedForeground: '215 20% 65.1%', + accent: '50 100% 50%', + accentForeground: '260 40% 6%', + destructive: '0 100% 50%', + destructiveForeground: '210 40% 98%', + border: '260 30% 14%', + input: '260 30% 14%', + ring: '270 100% 60%', +} + +const AURORA_BOREALIS_THEME: ColorConfig = { + background: '240 40% 6%', + foreground: '210 40% 98%', + card: '240 38% 8%', + cardForeground: '210 40% 98%', + popover: '240 38% 8%', + popoverForeground: '210 40% 98%', + primary: '180 100% 45%', + primaryForeground: '240 40% 6%', + secondary: '120 100% 48%', + secondaryForeground: '240 40% 6%', + muted: '240 30% 18%', + mutedForeground: '215 20% 65.1%', + accent: '300 100% 50%', + accentForeground: '240 40% 6%', + destructive: '0 100% 50%', + destructiveForeground: '210 40% 98%', + border: '240 30% 14%', + input: '240 30% 14%', + ring: '180 100% 55%', +} + export const THEME_PRESETS: Array<{ name: string colors: ColorConfig description: string + category?: 'basic' | 'animated' | 'gaming' | 'seasonal' | 'special' + isGaming?: boolean + themeId?: string + backgroundEffect?: string + animationSpeed?: string }> = [ + // Basic themes { name: 'Default Dark', colors: DEFAULT_COLORS, description: 'Baseline Emberly palette with balanced contrast.', + category: 'basic', + themeId: 'default-dark', }, { name: 'Hawkins Neon', colors: STRANGER_THINGS_THEME, description: 'Stranger Things-inspired deep midnight with neon red + blue.', + category: 'basic', + themeId: 'hawkins-neon', + backgroundEffect: 'glitch', + animationSpeed: 'slow', + }, + { + name: '🙃 The Upside Down', + colors: UPSIDE_DOWN_THEME, + description: 'Enter the shadow realm where everything is reversed.', + category: 'animated', + isGaming: false, + themeId: 'upside-down', + backgroundEffect: 'particles', + animationSpeed: 'slow', }, + // Seasonal themes { name: 'Holly Jolly (Christmas)', colors: CHRISTMAS_THEME, description: 'Festive green + red with gold accents for the holidays.', + category: 'seasonal', + themeId: 'holly-jolly', }, + // Special cause themes { name: 'Pride Bright', colors: PRIDE_THEME, description: 'Vibrant accents inspired by the Pride rainbow.', + category: 'special', + themeId: 'pride-bright', }, { name: 'Every Child Matters', colors: EVERY_CHILD_THEME, description: 'A respectful orange-themed palette to mark awareness and remembrance.', + category: 'special', + themeId: 'every-child-matters', }, { name: 'Remembrance', colors: REMEMBRANCE_THEME, description: 'A muted palette with remembrance red highlights.', + category: 'special', + themeId: 'remembrance', + }, + // Gaming themes + { + name: '🕹️ Retro Arcade', + colors: RETRO_ARCADE_THEME, + description: 'Classic 80s arcade aesthetic with magenta and yellow neon.', + category: 'gaming', + isGaming: true, + themeId: 'retro-arcade', + backgroundEffect: 'scanlines', + animationSpeed: 'medium', + }, + { + name: '🤖 Cyberpunk Neon', + colors: CYBERPUNK_NEON_THEME, + description: 'Futuristic cyberpunk with cyan and magenta chroma aberration.', + category: 'gaming', + isGaming: true, + themeId: 'cyberpunk-neon', + backgroundEffect: 'glitch', + animationSpeed: 'fast', + }, + { + name: '💜 Vaporwave', + colors: VAPORWAVE_THEME, + description: 'Aesthetic vaporwave with purple, pink, and cyan pastels.', + category: 'gaming', + isGaming: false, + themeId: 'vaporwave', + backgroundEffect: 'gradient-shift', + animationSpeed: 'slow', + }, + { + name: '💚 Dark Matrix', + colors: DARK_MATRIX_THEME, + description: 'The Matrix inspired with green code rain effects.', + category: 'gaming', + isGaming: true, + themeId: 'dark-matrix', + backgroundEffect: 'matrix', + animationSpeed: 'slow', + }, + { + name: '📊 Neon Grid', + colors: NEON_GRID_THEME, + description: 'Grid-based interface with cyan and magenta neon accents.', + category: 'gaming', + isGaming: true, + themeId: 'neon-grid', + backgroundEffect: 'grid', + animationSpeed: 'medium', + }, + { + name: '🌠 Cosmic Space', + colors: COSMIC_SPACE_THEME, + description: 'Space exploration theme with purple and gold starfield.', + category: 'gaming', + isGaming: true, + themeId: 'cosmic-space', + backgroundEffect: 'parallax', + animationSpeed: 'slow', + }, + // Animated themes + { + name: '🌌 Aurora Borealis', + colors: AURORA_BOREALIS_THEME, + description: 'Northern lights inspired theme with cyan and purple aurora effects.', + category: 'animated', + isGaming: false, + themeId: 'aurora-borealis', + backgroundEffect: 'aurora', + animationSpeed: 'slow', }, ] @@ -294,6 +577,7 @@ export const PRESET_HUES = [ function SimpleThemeCustomizer({ onColorChange, + onThemePresetChange, initialColors, }: ThemeCustomizerProps) { const [baseHue, setBaseHue] = useState(222.2) @@ -363,7 +647,7 @@ function SimpleThemeCustomizer({ // mark theme name on document so site-level features can react (e.g., snowfall) try { - document.documentElement.setAttribute('data-theme', 'Default Dark') + document.documentElement.setAttribute('data-theme', 'default-dark') } catch (e) { // noop } @@ -391,26 +675,34 @@ function SimpleThemeCustomizer({ onColorChange({ [key]: value }) } - const applyPresetTheme = (preset: ColorConfig) => { - Object.entries(preset).forEach(([key, value]) => { + const applyPresetTheme = (preset: typeof THEME_PRESETS[0]) => { + Object.entries(preset.colors).forEach(([key, value]) => { const cssKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`) document.documentElement.style.setProperty(`--${cssKey}`, value) }) - const hue = parseFloat(preset.background.split(' ')[0] || `${baseHue}`) + const hue = parseFloat(preset.colors.background.split(' ')[0] || `${baseHue}`) if (!Number.isNaN(hue)) { setBaseHue(hue) } - setColors(preset) + setColors(preset.colors) // set a document attribute naming the preset so global UI can react try { - // use a short name safe for attributes - document.documentElement.setAttribute('data-theme', preset.name) + // use themeId if available, otherwise use preset name + const themeIdentifier = preset.themeId || preset.name.replace(/[^\w-]/g, '').toLowerCase() + document.documentElement.setAttribute('data-theme', themeIdentifier) } catch (e) { // noop in environments that restrict DOM } - onColorChange(preset) + onColorChange(preset.colors) + + // Call theme preset change callback if provided + if (onThemePresetChange && preset.themeId) { + const backgroundEffect = (preset as any).backgroundEffect || 'none' + const animationSpeed = (preset as any).animationSpeed || 'medium' + onThemePresetChange(preset.themeId, backgroundEffect, animationSpeed) + } } const handleColorChange = (key: keyof ColorConfig, value: string) => { @@ -439,32 +731,61 @@ function SimpleThemeCustomizer({ return (
+ {/* Categorized Presets with Tabs */}
Curated themes
-
- {THEME_PRESETS.map((preset) => ( - - ))} -
+ {(() => { + const themesByCategory = THEME_PRESETS.reduce((acc, preset) => { + const category = (preset as any).category || 'basic' + if (!acc[category]) { + acc[category] = [] + } + acc[category].push(preset) + return acc + }, {} as Record) + + return ( + + + {sortCategories(Object.keys(themesByCategory) as any[]).map((category) => ( + + {getCategoryIcon(category)} + {getCategoryLabel(category)} + + ))} + + + {sortCategories(Object.keys(themesByCategory) as any[]).map((category) => ( + +
+ {themesByCategory[category].map((preset) => ( + + ))} +
+
+ ))} +
+ ) + })()}
@@ -533,12 +854,15 @@ function SimpleThemeCustomizer({ export function ThemeCustomizer({ onColorChange, + onThemePresetChange, initialColors, }: ThemeCustomizerProps) { return ( ) } + diff --git a/packages/components/theme/theme-effects-wrapper.tsx b/packages/components/theme/theme-effects-wrapper.tsx new file mode 100644 index 0000000..c6bb639 --- /dev/null +++ b/packages/components/theme/theme-effects-wrapper.tsx @@ -0,0 +1,153 @@ +'use client' + +import React, { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTheme, useThemeConfig } from '@/packages/lib/theme/theme-context' +import { AnimatedBackground } from '@/packages/components/theme/animated-background' +import { GamingBackground } from '@/packages/components/theme/gaming-background' + +const CANVAS_EFFECTS = ['particles', 'gradient-shift', 'waves', 'glitch', 'grid', 'parallax', 'aurora', 'stars', 'matrix', 'scanlines'] + +/** + * Wrapper component that renders theme effects (particles, animations, etc.) + * Uses the unified theme context for cleaner state management + */ +export const ThemeEffectsWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { config, effectsEnabled, metadata } = useTheme() + const [container, setContainer] = useState(null) + const [isHydrated, setIsHydrated] = useState(false) + + // Find server-rendered container on mount + useEffect(() => { + const el = document.getElementById('theme-effects-root') + setContainer(el) + // Mark as hydrated after mount + setIsHydrated(true) + }, []) + + // Determine what effects to render - ONLY if metadata explicitly supports it + const effect = config?.backgroundEffect || 'none' + + // Only show effects if: + // 1. Component is hydrated (prevents flash during SSR/hydration) + // 2. Effects are enabled by user preference + // 3. The theme has a known metadata entry (not unknown theme) + // 4. The metadata explicitly supports effects (supportsFx: true) + // 5. The background effect is not 'none' + const shouldShowEffects = isHydrated && + effectsEnabled && + metadata !== null && + metadata.supportsFx === true && + effect !== 'none' && + CANVAS_EFFECTS.includes(effect) + + const hasCanvasEffect = shouldShowEffects + const hasGamingEffect = shouldShowEffects && metadata?.isGaming === true + + // Debug logging (only in development) + useEffect(() => { + if (process.env.NODE_ENV === 'development') { + console.log('[ThemeEffectsWrapper] Theme:', config?.theme) + console.log('[ThemeEffectsWrapper] Metadata:', metadata?.id, 'supportsFx:', metadata?.supportsFx) + console.log('[ThemeEffectsWrapper] Effect:', effect) + console.log('[ThemeEffectsWrapper] effectsEnabled:', effectsEnabled) + console.log('[ThemeEffectsWrapper] isHydrated:', isHydrated) + console.log('[ThemeEffectsWrapper] shouldShowEffects:', shouldShowEffects) + console.log('[ThemeEffectsWrapper] hasCanvasEffect:', hasCanvasEffect) + console.log('[ThemeEffectsWrapper] hasGamingEffect:', hasGamingEffect) + } + }, [config, metadata, effect, effectsEnabled, isHydrated, shouldShowEffects, hasCanvasEffect, hasGamingEffect]) + + return ( + <> + {container && (hasCanvasEffect || hasGamingEffect) && createPortal( + <> + {hasCanvasEffect && ( + + )} + {hasGamingEffect && ( + + )} + , + container + )} +
{children}
+ + ) +} + +/** + * Fallback wrapper that works without the context (for backwards compatibility) + * Uses the legacy useThemeEffects hook behavior + */ +export const ThemeEffectsWrapperLegacy: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const config = useThemeConfig() + const [effectsEnabled, setEffectsEnabled] = useState(true) + const [container, setContainer] = useState(null) + + // Listen for effects toggle changes + useEffect(() => { + const checkEffectsState = () => { + const stored = localStorage.getItem('emberly-effects-enabled') + setEffectsEnabled(stored === null ? true : stored === 'true') + } + + checkEffectsState() + window.addEventListener('storage', checkEffectsState) + + const observer = new MutationObserver(() => { + const disabled = document.documentElement.getAttribute('data-effects-disabled') + setEffectsEnabled(disabled !== 'true') + }) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-effects-disabled'] }) + + return () => { + window.removeEventListener('storage', checkEffectsState) + observer.disconnect() + } + }, []) + + useEffect(() => { + const el = document.getElementById('theme-effects-root') + setContainer(el) + }, []) + + const effect = config?.backgroundEffect || 'none' + const hasCanvasEffect = effectsEnabled && effect !== 'none' && CANVAS_EFFECTS.includes(effect) + const hasGamingEffect = effectsEnabled && config?.type === 'gaming' && effect !== 'none' + + return ( + <> + {container && (hasCanvasEffect || hasGamingEffect) && createPortal( + <> + {hasCanvasEffect && ( + + )} + {hasGamingEffect && ( + + )} + , + container + )} +
{children}
+ + ) +} diff --git a/packages/components/theme/theme-initializer.tsx b/packages/components/theme/theme-initializer.tsx index 089999c..297473e 100644 --- a/packages/components/theme/theme-initializer.tsx +++ b/packages/components/theme/theme-initializer.tsx @@ -1,16 +1,34 @@ import { getConfig } from '@/packages/lib/config' -export async function ThemeInitializer() { - const config = await getConfig() - const customColors = config.settings.appearance.customColors || {} - const themeName = config.settings.appearance.theme || '' +type ThemeInitializerProps = { + userTheme?: string | null + userCustomColors?: Record | null +} + +export async function ThemeInitializer({ userTheme, userCustomColors }: ThemeInitializerProps) { + let cssVariables: string + let themeName: string - const cssVariables = Object.entries(customColors) - .map(([key, value]) => { - const cssKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`) - return `--${cssKey}: ${value};` - }) - .join('\n') + if (userTheme) { + const customColors = userCustomColors || {} + cssVariables = Object.entries(customColors) + .map(([key, value]) => { + const cssKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`) + return `--${cssKey}: ${value};` + }) + .join('\n') + themeName = userTheme + } else { + const config = await getConfig() + const customColors = config.settings.appearance.customColors || {} + themeName = config.settings.appearance.theme || '' + cssVariables = Object.entries(customColors) + .map(([key, value]) => { + const cssKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`) + return `--${cssKey}: ${value};` + }) + .join('\n') + } return ( <> @@ -29,7 +47,7 @@ export async function ThemeInitializer() { }} />