Compare commits
4 Commits
e7e06ea875
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ccb3495d3 | |||
| c84492cd7e | |||
| 7d8d050db0 | |||
| 7f9ebc6bc2 |
@@ -24,6 +24,8 @@ OpenChat Overlay is a lightweight, open-source Twitch chat overlay solution desi
|
||||
TWITCH_CLIENT_ID=...
|
||||
TWITCH_CLIENT_SECRET=...
|
||||
```
|
||||
> **Note:** `TWITCH_CLIENT_ID` and `TWITCH_CLIENT_SECRET` are required for both User Authentication (NextAuth) and the Support Module (Stream Status).
|
||||
|
||||
4. Run the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
@@ -33,6 +35,25 @@ OpenChat Overlay is a lightweight, open-source Twitch chat overlay solution desi
|
||||
- The application uses **Next.js App Router**.
|
||||
- **Dashboard:** Located in `src/app/(dashboard)`. Protected by authentication.
|
||||
- **Overlay:** Located in `src/app/overlay/[token]`. Publicly accessible but requires a valid token. Renders with a transparent background.
|
||||
- **API:**
|
||||
- `/api/auth/*`: Handles user login via Twitch.
|
||||
- `/api/twitch/status`: Public endpoint to check if the developer (`ramforth`) is live, used by the dashboard support module.
|
||||
|
||||
## Features
|
||||
- **Style Creator:** Customise font, size, colors, opacity, and toggle badges/emotes.
|
||||
- **Resolution Support:** Toggle between 720p and 1080p base resolutions.
|
||||
- **Live Preview:** Real-time visual feedback of chat settings.
|
||||
- **Support Module:** Sidebar widget showing developer status and social links.
|
||||
|
||||
## Deployment
|
||||
(To be added: Instructions for building the Docker image and deploying to an LXC container.)
|
||||
|
||||
### Infrastructure Overview
|
||||
- **Development:** Conducted on local desktop (CachyOS).
|
||||
- **Version Control:** Synced to `gitea.ramforth.net` under the `RamTech` organization.
|
||||
- **Production Host:** Standalone Proxmox LXC container or VM.
|
||||
- **Service URL:** Production will NOT be hosted on `localhost`. All external callbacks (Twitch Auth) must point to the production domain/IP.
|
||||
|
||||
### Deployment Workflow
|
||||
1. Push changes from local development machine to Gitea.
|
||||
2. Pull changes on the production LXC/VM.
|
||||
3. Build and run using Docker or as a systemd service.
|
||||
30
PLAN.md
30
PLAN.md
@@ -11,7 +11,7 @@ To build "OpenChat Overlay"—a free, open-source alternative to StreamElements,
|
||||
|
||||
## Architecture
|
||||
|
||||
### Next.js Folder Structure (Proposed)
|
||||
### Next.js Folder Structure
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
@@ -25,8 +25,10 @@ src/
|
||||
│ ├── api/ # API Routes
|
||||
│ │ ├── auth/ # NextAuth handlers
|
||||
│ │ │ └── [...nextauth]/route.ts
|
||||
│ │ └── overlay/ # Overlay configuration endpoints
|
||||
│ │ └── [token]/route.ts
|
||||
│ │ ├── overlay/ # Overlay configuration endpoints
|
||||
│ │ │ └── [token]/route.ts
|
||||
│ │ └── twitch/ # Twitch API proxy
|
||||
│ │ └── status/ # Check stream status
|
||||
│ ├── overlay/ # The public overlay view
|
||||
│ │ └── [token]/ # Dynamic route for user overlay
|
||||
│ │ ├── layout.tsx # Transparent background layout
|
||||
@@ -35,13 +37,15 @@ src/
|
||||
├── components/
|
||||
│ ├── dashboard/ # Dashboard-specific components
|
||||
│ │ ├── PreviewFrame.tsx # Live preview iframe/component
|
||||
│ │ └── StyleControls.tsx # Form for editing styles
|
||||
│ │ ├── StyleControls.tsx # Form for editing styles
|
||||
│ │ └── SupportModule.tsx # Sidebar support & social module
|
||||
│ ├── overlay/ # Overlay-specific components
|
||||
│ │ ├── ChatContainer.tsx
|
||||
│ │ └── Message.tsx
|
||||
│ └── ui/ # Reusable UI components (buttons, inputs)
|
||||
├── lib/
|
||||
│ ├── supabase.ts # Supabase client
|
||||
│ ├── twitch.ts # Twitch API helpers
|
||||
│ ├── utils.ts # Helper functions
|
||||
│ └── types.ts # TypeScript interfaces
|
||||
└── hooks/ # Custom React hooks (useChat, useSettings)
|
||||
@@ -55,7 +59,11 @@ src/
|
||||
|
||||
### 2. The "Style Creator"
|
||||
- **Left Panel:** Controls for font family, size, colors, opacity, badge toggles, and 7TV/BTTV emote toggles.
|
||||
- **Enhanced Settings:**
|
||||
- Resolution Selection (720p/1080p).
|
||||
- Quick "Copy Overlay URL" button.
|
||||
- **Right Panel:** Live preview component rendering mock chat messages with the current settings applied.
|
||||
- **Support Module:** Sidebar widget showing developer's live status (via Twitch API) and social/donation links.
|
||||
- **State:** React state manages unsaved changes; "Save" pushes to Supabase.
|
||||
|
||||
### 3. The "Secret Sauce" URL (`/overlay/[token]`)
|
||||
@@ -71,11 +79,18 @@ src/
|
||||
- **Library:** `tmi.js` (client-side execution).
|
||||
- **Cost:** Zero server-side WebSocket processing.
|
||||
|
||||
## Hosting & Infrastructure
|
||||
- **Target:** Proxmox LXC or VM (standalone).
|
||||
- **Architecture:** Client-side processing via `tmi.js` to ensure the server handles only authentication and configuration serving.
|
||||
- **Cost Efficiency:** Designed for "near-zero" server overhead to allow hosting on minimal hardware resources.
|
||||
- **Environment:** The development machine is exclusively for coding; the service will never be hosted on the dev machine.
|
||||
|
||||
## Implementation Phases
|
||||
1. **Project Initialization:** Scaffolding Next.js, Tailwind, and Supabase client.
|
||||
2. **Authentication:** Implementing NextAuth with Twitch.
|
||||
1. **Project Initialization:** Scaffolding Next.js, Tailwind, and Supabase client. [Completed]
|
||||
2. **Authentication:** Implementing NextAuth with Twitch. [In Progress]
|
||||
3. **Database:** Designing the Schema and connecting Supabase.
|
||||
4. **Dashboard UI:** Building the Style Creator and Preview.
|
||||
4. **Dashboard UI:** Building the Style Creator and Preview. [Completed]
|
||||
- *Added:* Support Module, Resolution settings, Copy Link.
|
||||
5. **Overlay Logic:** Creating the standalone overlay page and chat connection.
|
||||
6. **Deployment:** Docker/LXC containerization.
|
||||
|
||||
@@ -91,6 +106,7 @@ src/
|
||||
"backgroundColor": "#000000",
|
||||
"backgroundOpacity": 0.5,
|
||||
"showBadges": true,
|
||||
"resolution": "1080p",
|
||||
"emotes": {
|
||||
"bttv": true,
|
||||
"seventv": true
|
||||
|
||||
163
package-lock.json
generated
163
package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"next": "16.1.1",
|
||||
"next-auth": "^4.24.13",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
@@ -231,6 +232,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
@@ -1229,6 +1239,15 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@panva/hkdf": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -2632,6 +2651,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -4421,6 +4449,15 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "4.15.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -5023,6 +5060,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-auth": {
|
||||
"version": "4.24.13",
|
||||
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz",
|
||||
"integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@panva/hkdf": "^1.0.2",
|
||||
"cookie": "^0.7.0",
|
||||
"jose": "^4.15.5",
|
||||
"oauth": "^0.9.15",
|
||||
"openid-client": "^5.4.0",
|
||||
"preact": "^10.6.3",
|
||||
"preact-render-to-string": "^5.1.19",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@auth/core": "0.34.3",
|
||||
"next": "^12.2.5 || ^13 || ^14 || ^15 || ^16",
|
||||
"nodemailer": "^7.0.7",
|
||||
"react": "^17.0.2 || ^18 || ^19",
|
||||
"react-dom": "^17.0.2 || ^18 || ^19"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@auth/core": {
|
||||
"optional": true
|
||||
},
|
||||
"nodemailer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
@@ -5058,6 +5127,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oauth": {
|
||||
"version": "0.9.15",
|
||||
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
||||
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -5068,6 +5143,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-hash": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -5181,6 +5265,48 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/oidc-token-hash": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
|
||||
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^10.13.0 || >=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jose": "^4.15.9",
|
||||
"lru-cache": "^6.0.0",
|
||||
"object-hash": "^2.2.0",
|
||||
"oidc-token-hash": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client/node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -5347,6 +5473,28 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.28.1",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.1.tgz",
|
||||
"integrity": "sha512-u1/ixq/lVQI0CakKNvLDEcW5zfCjUQfZdK9qqWuIJtsezuyG6pk9TWj75GMuI/EzRSZB/VAE43sNWWZfiy8psw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/preact-render-to-string": {
|
||||
"version": "5.2.6",
|
||||
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
|
||||
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pretty-format": "^3.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -5357,6 +5505,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
|
||||
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -6408,6 +6562,15 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"next": "16.1.1",
|
||||
"next-auth": "^4.24.13",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
|
||||
29
src/app/api/auth/[...nextauth]/route.ts
Normal file
29
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import NextAuth from "next-auth"
|
||||
import TwitchProvider from "next-auth/providers/twitch"
|
||||
|
||||
const handler = NextAuth({
|
||||
providers: [
|
||||
TwitchProvider({
|
||||
clientId: process.env.TWITCH_CLIENT_ID!,
|
||||
clientSecret: process.env.TWITCH_CLIENT_SECRET!,
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async session({ session, token }) {
|
||||
if (session?.user) {
|
||||
// Pass the user ID to the session
|
||||
// Note: We might need to extend the type definition for session.user
|
||||
(session.user as any).id = token.sub;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export { handler as GET, handler as POST }
|
||||
15
src/app/api/twitch/status/route.ts
Normal file
15
src/app/api/twitch/status/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getTwitchStatus } from '@/lib/twitch';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const username = searchParams.get('username');
|
||||
|
||||
if (!username) {
|
||||
return NextResponse.json({ error: 'Username is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const isLive = await getTwitchStatus(username);
|
||||
|
||||
return NextResponse.json({ isLive });
|
||||
}
|
||||
@@ -18,11 +18,11 @@ export default function Home() {
|
||||
</Link>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
||||
href="https://github.com"
|
||||
href="https://gitea.ramforth.net/RamTech/twitchChatOverlayServer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View on GitHub
|
||||
View on Gitea
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
50
src/components/AuthButtons.tsx
Normal file
50
src/components/AuthButtons.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { signIn, signOut, useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function LoginButton() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => signIn("twitch", { callbackUrl: "/editor" })}
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-[#9146FF] text-white gap-2 hover:bg-[#7a3acc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
||||
>
|
||||
Login with Twitch
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogoutButton() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserProfile() {
|
||||
const { data: session } = useSession();
|
||||
|
||||
if (!session?.user) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{session.user.image && (
|
||||
<img
|
||||
src={session.user.image}
|
||||
alt={session.user.name || "User"}
|
||||
className="w-8 h-8 rounded-full border border-zinc-200 dark:border-zinc-800"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{session.user.name}
|
||||
</span>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { OverlaySettings } from '@/lib/types';
|
||||
import SupportModule from './SupportModule';
|
||||
|
||||
interface StyleControlsProps {
|
||||
settings: OverlaySettings;
|
||||
@@ -9,6 +10,8 @@ interface StyleControlsProps {
|
||||
}
|
||||
|
||||
export default function StyleControls({ settings, updateSettings }: StyleControlsProps) {
|
||||
const [copySuccess, setCopySuccess] = React.useState(false);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
let newValue: string | number | boolean = value;
|
||||
@@ -32,9 +35,53 @@ export default function StyleControls({ settings, updateSettings }: StyleControl
|
||||
});
|
||||
};
|
||||
|
||||
const copyLink = () => {
|
||||
// Mock token for now
|
||||
const url = `${window.location.origin}/overlay/mock-token`;
|
||||
navigator.clipboard.writeText(url);
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 bg-white dark:bg-zinc-900 border-r border-zinc-200 dark:border-zinc-800 h-full overflow-y-auto w-80 shrink-0">
|
||||
|
||||
{/* Copy Link Section */}
|
||||
<div className="p-4 bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-100 dark:border-indigo-800 rounded-lg">
|
||||
<h3 className="text-xs font-semibold text-indigo-900 dark:text-indigo-200 uppercase tracking-wider mb-2">Overlay URL</h3>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
readOnly
|
||||
value="/overlay/..."
|
||||
className="flex-1 min-w-0 bg-white dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 rounded text-xs px-2 py-1 text-zinc-500"
|
||||
/>
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white text-xs px-3 py-1 rounded transition-colors"
|
||||
>
|
||||
{copySuccess ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-bold text-zinc-900 dark:text-white">Style Editor</h2>
|
||||
<p className="text-xs text-zinc-500">Customize your chat appearance</p>
|
||||
</div>
|
||||
|
||||
{/* Resolution */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Base Resolution</label>
|
||||
<select
|
||||
name="resolution"
|
||||
value={settings.resolution}
|
||||
onChange={handleChange}
|
||||
className="w-full p-2 border rounded-md bg-zinc-50 dark:bg-zinc-800 border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-white"
|
||||
>
|
||||
<option value="720p">720p (HD)</option>
|
||||
<option value="1080p">1080p (Full HD)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Font Family */}
|
||||
<div className="space-y-2">
|
||||
@@ -163,6 +210,8 @@ export default function StyleControls({ settings, updateSettings }: StyleControl
|
||||
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">7TV Emotes</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<SupportModule />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
104
src/components/dashboard/SupportModule.tsx
Normal file
104
src/components/dashboard/SupportModule.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
const DEVELOPER_USERNAME = 'ramforth'; // Hardcoded developer username
|
||||
|
||||
export default function SupportModule() {
|
||||
const [isLive, setIsLive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/twitch/status?username=${DEVELOPER_USERNAME}`);
|
||||
const data = await res.json();
|
||||
setIsLive(data.isLive);
|
||||
} catch (error) {
|
||||
console.error('Failed to check Twitch status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
// Poll every 5 minutes
|
||||
const interval = setInterval(checkStatus, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mt-auto pt-6 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-4">
|
||||
Support & Social
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Live Indicator / Watch Button */}
|
||||
<a
|
||||
href={`https://twitch.tv/${DEVELOPER_USERNAME}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`
|
||||
group relative flex items-center justify-between p-3 rounded-lg border transition-all duration-300
|
||||
${isLive
|
||||
? 'bg-zinc-900 border-indigo-500/50 hover:border-indigo-500 hover:shadow-[0_0_15px_-3px_rgba(99,102,241,0.4)]'
|
||||
: 'bg-zinc-50 dark:bg-zinc-800/50 border-zinc-200 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Avatar / Icon Placeholder */}
|
||||
<div className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center
|
||||
${isLive ? 'bg-indigo-600 text-white' : 'bg-zinc-200 dark:bg-zinc-700 text-zinc-500'}
|
||||
`}>
|
||||
{/* Simple Twitch Icon SVG */}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="transform scale-90">
|
||||
<path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h2.143L22.286 11.143V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className={`text-xs font-bold ${isLive ? 'text-indigo-400' : 'text-zinc-700 dark:text-zinc-300'}`}>
|
||||
ramforth
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-500">Developer</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLive ? (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-red-500/10 border border-red-500/20">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-red-500 uppercase tracking-wide">LIVE</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-zinc-400 group-hover:text-zinc-600 dark:group-hover:text-zinc-200 transition-colors">
|
||||
Offline
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<a
|
||||
href={`https://twitch.tv/${DEVELOPER_USERNAME}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium text-white bg-[#9146FF] hover:bg-[#7a3acc] rounded-md transition-colors"
|
||||
>
|
||||
<span>Follow</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
/* Replace # with actual donation link e.g., ko-fi.com/ramforth */
|
||||
className="flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium text-zinc-700 dark:text-zinc-200 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-md transition-colors border border-zinc-200 dark:border-zinc-700"
|
||||
>
|
||||
<span>❤️ Donate</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/lib/twitch.ts
Normal file
54
src/lib/twitch.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// src/lib/twitch.ts
|
||||
|
||||
export async function getTwitchStatus(username: string) {
|
||||
const clientId = process.env.TWITCH_CLIENT_ID;
|
||||
const clientSecret = process.env.TWITCH_CLIENT_SECRET;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
console.error('Missing Twitch API credentials');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Get App Access Token
|
||||
const tokenResponse = await fetch('https://id.twitch.tv/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: 'client_credentials',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error('Failed to get Twitch access token');
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
const accessToken = tokenData.access_token;
|
||||
|
||||
// 2. Check Stream Status
|
||||
const streamResponse = await fetch(`https://api.twitch.tv/helix/streams?user_login=${username}`, {
|
||||
headers: {
|
||||
'Client-ID': clientId,
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!streamResponse.ok) {
|
||||
throw new Error('Failed to fetch stream status');
|
||||
}
|
||||
|
||||
const streamData = await streamResponse.json();
|
||||
|
||||
// If the data array is not empty, the user is live
|
||||
return streamData.data && streamData.data.length > 0;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching Twitch status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export interface OverlaySettings {
|
||||
textShadow: string;
|
||||
messageSpacing: string;
|
||||
usernameColor: string; // 'custom' or 'twitch'
|
||||
resolution: '720p' | '1080p';
|
||||
emotes: {
|
||||
bttv: boolean;
|
||||
seventv: boolean;
|
||||
@@ -25,6 +26,7 @@ export const DEFAULT_SETTINGS: OverlaySettings = {
|
||||
textShadow: '1px 1px 2px rgba(0,0,0,0.8)',
|
||||
messageSpacing: '0.5rem',
|
||||
usernameColor: 'twitch',
|
||||
resolution: '1080p',
|
||||
emotes: {
|
||||
bttv: true,
|
||||
seventv: true,
|
||||
|
||||
Reference in New Issue
Block a user