SaleemFiverr commited on
Commit
1108f91
·
1 Parent(s): bca2b50

Fix favicon and enhance SEO with standard Google practices

Browse files
.gitignore ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .DS_Store
3
+ *.log
4
+ .env
5
+ .env.*
6
+ !.env.example
7
+
8
+ # Build artifacts
9
+ dist/
10
+ build/
11
+ .react-router/
12
+ .vite/
13
+
14
+ # Workspace specific
15
+ web/build/
16
+ web/.react-router/
17
+ web/.vite/
18
+
19
+ # Hugging Face Restrictions (Binary Files)
20
+ mobile/
21
+ **/*.png
22
+ **/*.ico
23
+ **/*.wasm
24
+ **/*.jpg
25
+ **/*.jpeg
26
+ **/*.gif
27
+ **/*.webp
28
+
.nvmrc ADDED
@@ -0,0 +1 @@
 
 
1
+ 22.20.0
DEPLOYMENT.md ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🚀 Deployment Guide for Untwitch (React Router v7 + Hono)
2
+
3
+ This guide will walk you through deploying Untwitch to Vercel via GitHub.
4
+
5
+ ---
6
+
7
+ ## Prerequisites
8
+
9
+ ✅ GitHub account
10
+ ✅ Vercel account (free - sign up at [vercel.com](https://vercel.com))
11
+ ✅ Your code ready to push
12
+
13
+ ---
14
+
15
+ ## Step 1: Prepare Your Repository
16
+
17
+ ### 1.1 Initialize Git (if not already done)
18
+
19
+ ```bash
20
+ git init
21
+ git add .
22
+ git commit -m "Initial commit: Untwitch project ready for deployment"
23
+ ```
24
+
25
+ ### 1.2 Create a GitHub Repository
26
+
27
+ 1. Go to [github.com](https://github.com) and sign in
28
+ 2. Click the "+" icon in the top right → "New repository"
29
+ 3. Name it `untwitch`
30
+ 4. Click "Create repository"
31
+
32
+ ### 1.3 Push Your Code to GitHub
33
+
34
+ ```bash
35
+ git remote add origin https://github.com/YOUR_USERNAME/untwitch.git
36
+ git branch -M main
37
+ git push -u origin main
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Step 2: Deploy to Vercel
43
+
44
+ 1. **Go to Vercel**
45
+ Visit [vercel.com](https://vercel.com) and sign in with GitHub.
46
+
47
+ 2. **Import Your Repository**
48
+ - Click "Add New..." → "Project"
49
+ - Select your `untwitch` repository.
50
+ - Click "Import"
51
+
52
+ 3. **Configure Project Settings**
53
+ **Crucial Step**: Vercel might auto-detect "Other" or "Vite". Configure as follows:
54
+ - **Framework Preset**: `Other` (or `Vite`)
55
+ - **Root Directory**: `web`
56
+ - **Build Command**: `npm run build`
57
+ - **Output Directory**: `build/client`
58
+ - **Install Command**: `npm install`
59
+
60
+ 4. **Add Environment Variables**
61
+ If you have a database or secret keys, add them here:
62
+ - `DATABASE_URL` (Neon PostgreSQL)
63
+ - `AUTH_SECRET` (For authentication)
64
+ - `NEXT_PUBLIC_CREATE_BASE_URL` (Defaults to https://www.create.xyz)
65
+
66
+ 5. **Deploy!**
67
+ - Click "Deploy"
68
+ - Wait ~2 minutes. Your site will be live!
69
+
70
+ ---
71
+
72
+ ## Step 3: Why This Setup?
73
+
74
+ Untwitch uses a hybrid architecture:
75
+ - **React Router v7**: For the frontend and Server-Side Rendering (SSR).
76
+ - **Hono**: A fast, standard-compliant server for both API and SSR.
77
+ - **Vercel Functions**: The `web/api/index.ts` file acts as a bridge, allowing Vercel to run the Hono server for SSR and API requests.
78
+
79
+ ---
80
+
81
+ ## Troubleshooting
82
+
83
+ ### API Routes 404
84
+ Ensure your `Root Directory` is set to `web`. The `web/vercel.json` handles rewrites to `/api/index`, which is our Hono entry point.
85
+
86
+ ### Build Fails
87
+ Verify that you are using Node.js 18+ in Vercel settings (General → Node.js Version).
88
+
89
+ ---
90
+
91
+ ## Summary
92
+
93
+ ✅ Code pushed to GitHub
94
+ ✅ Root Directory set to `web`
95
+ ✅ Build/Output paths configured correctly
96
+ ✅ Deployed successfully
97
+
98
+ **Your site is now live!** 🎉
Dockerfile CHANGED
@@ -1,35 +1,38 @@
1
- # Use the official Node.js 22 image as the base
2
- FROM node:22-slim
3
 
4
- # Install system dependencies: ffmpeg and curl (for health checks)
5
  RUN apt-get update && apt-get install -y \
6
  ffmpeg \
7
  curl \
8
  python3 \
 
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
  # Set the working directory
12
  WORKDIR /app
13
 
14
- # Copy the root package.json and workspace package.json
15
  COPY package*.json ./
16
  COPY web/package*.json ./web/
17
 
18
- # Install dependencies (using --legacy-peer-deps if needed)
19
- RUN npm install --include=dev
20
 
21
  # Copy the rest of the application code
22
  COPY . .
23
 
24
- # Build the web application
25
- RUN npm run build --workspace=web
26
-
27
- # Use environment variable for port (Hugging Face uses 7860)
28
  ENV NODE_ENV=production
29
  ENV PORT=7860
30
 
31
- # Expose the port
 
 
 
32
  EXPOSE 7860
33
 
34
- # Start the application and ensure it listens on the correct port
35
  CMD ["sh", "-c", "PORT=${PORT:-7860} npm start --workspace=web"]
 
1
+ # Use the full Node.js 22 image
2
+ FROM node:22
3
 
4
+ # Install system dependencies
5
  RUN apt-get update && apt-get install -y \
6
  ffmpeg \
7
  curl \
8
  python3 \
9
+ python-is-python3 \
10
  && rm -rf /var/lib/apt/lists/*
11
 
12
  # Set the working directory
13
  WORKDIR /app
14
 
15
+ # Copy package management files
16
  COPY package*.json ./
17
  COPY web/package*.json ./web/
18
 
19
+ # Install ALL dependencies (including devDependencies needed for build)
20
+ RUN npm install --legacy-peer-deps
21
 
22
  # Copy the rest of the application code
23
  COPY . .
24
 
25
+ # Set dummy environment variables for the build process
26
+ ENV DATABASE_URL="postgres://dummy:dummy@localhost:5432/dummy"
27
+ ENV AUTH_SECRET="dummy_secret_for_build_only"
 
28
  ENV NODE_ENV=production
29
  ENV PORT=7860
30
 
31
+ # Build the web application using npm workspace command
32
+ RUN npm run build --workspace=web
33
+
34
+ # Expose the port Hugging Face expects
35
  EXPOSE 7860
36
 
37
+ # Start the application
38
  CMD ["sh", "-c", "PORT=${PORT:-7860} npm start --workspace=web"]
vercel.json ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "installCommand": "npm install --legacy-peer-deps --omit=optional",
3
+ "buildCommand": "npm run build",
4
+ "outputDirectory": "web/build/client",
5
+ "framework": null,
6
+ "cleanUrls": true,
7
+ "trailingSlash": false,
8
+ "functions": {
9
+ "web/api/index.ts": {
10
+ "maxDuration": 60,
11
+ "memory": 1024,
12
+ "includeFiles": "web/build/server/**"
13
+ }
14
+ },
15
+ "rewrites": [
16
+ {
17
+ "source": "/api/(.*)",
18
+ "destination": "web/api/index"
19
+ },
20
+ {
21
+ "source": "/((?!assets/|favicon|robots\\.txt|sitemap\\.xml|manifest\\.json|browserconfig\\.xml).*)",
22
+ "destination": "web/api/index"
23
+ }
24
+ ],
25
+ "headers": [
26
+ {
27
+ "source": "/(.*)",
28
+ "headers": [
29
+ { "key": "X-Content-Type-Options", "value": "nosniff" },
30
+ { "key": "X-Frame-Options", "value": "DENY" },
31
+ { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
32
+ ]
33
+ }
34
+ ],
35
+ "build": {
36
+ "env": {
37
+ "NODE_VERSION": "22"
38
+ }
39
+ }
40
+ }
web/__create/index.ts CHANGED
@@ -1,5 +1,6 @@
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
  import nodeConsole from 'node:console';
 
3
  import { skipCSRFCheck } from '@auth/core';
4
  import Credentials from '@auth/core/providers/credentials';
5
  import { authHandler, initAuthConfig } from '@hono/auth-js';
@@ -47,6 +48,7 @@ const getAdapter = () => {
47
  };
48
 
49
  const app = new Hono();
 
50
 
51
  // Log environment variables (masked) for debugging Vercel issues
52
  console.log('--- Environment Check ---');
@@ -65,6 +67,138 @@ app.use('*', async (c, next) => {
65
 
66
  app.use(contextStorage());
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  app.onError((err, c) => {
69
  console.error(`[App Error] ${c.req.method} ${c.req.path}:`, err);
70
 
 
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
  import nodeConsole from 'node:console';
3
+ import { readFile } from 'node:fs/promises';
4
  import { skipCSRFCheck } from '@auth/core';
5
  import Credentials from '@auth/core/providers/credentials';
6
  import { authHandler, initAuthConfig } from '@hono/auth-js';
 
48
  };
49
 
50
  const app = new Hono();
51
+ const faviconUrl = new URL('../public/favicon.png', import.meta.url);
52
 
53
  // Log environment variables (masked) for debugging Vercel issues
54
  console.log('--- Environment Check ---');
 
67
 
68
  app.use(contextStorage());
69
 
70
+ app.get('/robots.txt', (c) => {
71
+ const robots = `User-agent: *
72
+ Allow: /
73
+ Sitemap: https://untwitch.online/sitemap.xml`;
74
+
75
+ return c.text(robots, 200, {
76
+ 'Content-Type': 'text/plain',
77
+ });
78
+ });
79
+
80
+ app.get('/sitemap.xml', (c) => {
81
+ const baseUrl = 'https://untwitch.online';
82
+ const mainPages = [
83
+ { path: '', priority: '1.0', changefreq: 'daily' },
84
+ { path: '/how-to-download', priority: '0.9', changefreq: 'weekly' },
85
+ { path: '/features', priority: '0.9', changefreq: 'weekly' },
86
+ { path: '/faq', priority: '0.8', changefreq: 'weekly' },
87
+ { path: '/blog', priority: '0.8', changefreq: 'daily' },
88
+ { path: '/contact', priority: '0.6', changefreq: 'monthly' },
89
+ { path: '/privacy-policy', priority: '0.5', changefreq: 'monthly' },
90
+ { path: '/terms', priority: '0.5', changefreq: 'monthly' },
91
+ ];
92
+ const blogPosts = [
93
+ '/blog/ultimate-guide-downloading-twitch-clips',
94
+ '/blog/download-twitch-vods-complete-guide',
95
+ '/blog/twitch-clip-vs-vod-downloader',
96
+ ];
97
+
98
+ const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
99
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
100
+ xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"
101
+ xmlns:xhtml="http://www.w3.org/1999/xhtml"
102
+ xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
103
+ xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
104
+ ${mainPages
105
+ .map(
106
+ (page) => `
107
+ <url>
108
+ <loc>${baseUrl}${page.path}</loc>
109
+ <lastmod>${new Date().toISOString()}</lastmod>
110
+ <changefreq>${page.changefreq}</changefreq>
111
+ <priority>${page.priority}</priority>
112
+ </url>`
113
+ )
114
+ .join('')}
115
+ ${blogPosts
116
+ .map(
117
+ (post) => `
118
+ <url>
119
+ <loc>${baseUrl}${post}</loc>
120
+ <lastmod>${new Date().toISOString()}</lastmod>
121
+ <changefreq>monthly</changefreq>
122
+ <priority>0.7</priority>
123
+ </url>`
124
+ )
125
+ .join('')}
126
+ </urlset>`;
127
+
128
+ return c.body(sitemap, 200, {
129
+ 'Content-Type': 'application/xml',
130
+ });
131
+ });
132
+
133
+ app.get('/favicon.svg', async (c) => {
134
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
135
+ <rect width="48" height="48" rx="12" fill="#7C3AED"/>
136
+ <path d="M24 10v20M14 20l10 10 10-10M12 38h24" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
137
+ </svg>`;
138
+ return c.body(svg, 200, {
139
+ 'Content-Type': 'image/svg+xml',
140
+ 'Cache-Control': 'public, max-age=86400',
141
+ });
142
+ });
143
+
144
+ app.get('/favicon.ico', async (c) => {
145
+ try {
146
+ const iconBuffer = await readFile(faviconUrl);
147
+ return c.body(iconBuffer, 200, {
148
+ 'Content-Type': 'image/x-icon',
149
+ 'Cache-Control': 'public, max-age=86400',
150
+ });
151
+ } catch (error) {
152
+ return c.text('Not Found', 404);
153
+ }
154
+ });
155
+
156
+ app.get('/favicon.png', async (c) => {
157
+ try {
158
+ const iconBuffer = await readFile(faviconUrl);
159
+ return c.body(iconBuffer, 200, {
160
+ 'Content-Type': 'image/png',
161
+ 'Cache-Control': 'public, max-age=86400',
162
+ });
163
+ } catch (error) {
164
+ return c.text('Not Found', 404);
165
+ }
166
+ });
167
+
168
+ app.get('/src/__create/favicon.png', async (c) => {
169
+ try {
170
+ const iconBuffer = await readFile(faviconUrl);
171
+ return c.body(iconBuffer, 200, {
172
+ 'Content-Type': 'image/png',
173
+ 'Cache-Control': 'public, max-age=86400',
174
+ });
175
+ } catch (error) {
176
+ return c.text('Not Found', 404);
177
+ }
178
+ });
179
+
180
+ app.get('/untwitch-icon.png', async (c) => {
181
+ try {
182
+ const highResUrl = new URL('../src/__create/logo-high-res.png', import.meta.url);
183
+ const iconBuffer = await readFile(highResUrl);
184
+ return c.body(iconBuffer, 200, {
185
+ 'Content-Type': 'image/png',
186
+ 'Cache-Control': 'public, max-age=86400',
187
+ });
188
+ } catch (error) {
189
+ // Fallback to regular favicon if high res is missing
190
+ try {
191
+ const iconBuffer = await readFile(faviconUrl);
192
+ return c.body(iconBuffer, 200, {
193
+ 'Content-Type': 'image/png',
194
+ 'Cache-Control': 'public, max-age=86400',
195
+ });
196
+ } catch (e) {
197
+ return c.text('Not Found', 404);
198
+ }
199
+ }
200
+ });
201
+
202
  app.onError((err, c) => {
203
  console.error(`[App Error] ${c.req.method} ${c.req.path}:`, err);
204
 
web/public/favicon.svg ADDED
web/src/app/blog/download-twitch-vods-complete-guide/page.jsx CHANGED
@@ -32,8 +32,71 @@ export const meta = () => [
32
  ];
33
 
34
  export default function DownloadTwitchVODs() {
 
 
 
35
  return (
36
  <article className="min-h-screen bg-white">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  {/* Header */}
38
  <header className="bg-gradient-to-br from-purple-600 to-purple-800 text-white py-16 px-4">
39
  <div className="max-w-4xl mx-auto space-y-6">
 
32
  ];
33
 
34
  export default function DownloadTwitchVODs() {
35
+ const publishDate = "2026-02-15T09:00:00+00:00";
36
+ const modifyDate = new Date().toISOString();
37
+
38
  return (
39
  <article className="min-h-screen bg-white">
40
+ {/* Schema Markup */}
41
+ <script
42
+ type="application/ld+json"
43
+ dangerouslySetInnerHTML={{
44
+ __html: JSON.stringify({
45
+ "@context": "https://schema.org",
46
+ "@type": "Article",
47
+ headline: "How to Download Twitch VODs: Complete Step-by-Step Guide",
48
+ description: "Master the art of downloading full Twitch VODs. Learn about quality settings, storage requirements, and best practices for archiving streams.",
49
+ image: "https://untwitch.online/untwitch-icon.png",
50
+ author: {
51
+ "@type": "Organization",
52
+ name: "Untwitch"
53
+ },
54
+ publisher: {
55
+ "@type": "Organization",
56
+ name: "Untwitch",
57
+ logo: {
58
+ "@type": "ImageObject",
59
+ url: "https://untwitch.online/untwitch-icon.png"
60
+ }
61
+ },
62
+ datePublished: publishDate,
63
+ dateModified: modifyDate,
64
+ mainEntityOfPage: {
65
+ "@type": "WebPage",
66
+ "@id": "https://untwitch.online/blog/download-twitch-vods-complete-guide"
67
+ }
68
+ }),
69
+ }}
70
+ />
71
+ <script
72
+ type="application/ld+json"
73
+ dangerouslySetInnerHTML={{
74
+ __html: JSON.stringify({
75
+ "@context": "https://schema.org",
76
+ "@type": "BreadcrumbList",
77
+ itemListElement: [
78
+ {
79
+ "@type": "ListItem",
80
+ position: 1,
81
+ name: "Home",
82
+ item: "https://untwitch.online",
83
+ },
84
+ {
85
+ "@type": "ListItem",
86
+ position: 2,
87
+ name: "Blog",
88
+ item: "https://untwitch.online/blog",
89
+ },
90
+ {
91
+ "@type": "ListItem",
92
+ position: 3,
93
+ name: "How to Download Twitch VODs",
94
+ item: "https://untwitch.online/blog/download-twitch-vods-complete-guide",
95
+ },
96
+ ],
97
+ }),
98
+ }}
99
+ />
100
  {/* Header */}
101
  <header className="bg-gradient-to-br from-purple-600 to-purple-800 text-white py-16 px-4">
102
  <div className="max-w-4xl mx-auto space-y-6">
web/src/app/blog/page.jsx CHANGED
@@ -1,3 +1,4 @@
 
1
  import { BookOpen, Clock, ArrowRight, TrendingUp } from "lucide-react";
2
 
3
  export const meta = () => [
@@ -10,6 +11,7 @@ export const meta = () => [
10
  ];
11
 
12
  export default function BlogPage() {
 
13
  const pillarPosts = [
14
  {
15
  title: "The Ultimate Guide to Downloading Twitch Clips in 2026",
@@ -58,6 +60,22 @@ export default function BlogPage() {
58
  "Legal",
59
  ];
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  return (
62
  <div className="min-h-screen bg-gradient-to-b from-purple-50 via-white to-gray-50">
63
  {/* Header */}
@@ -85,8 +103,10 @@ export default function BlogPage() {
85
  {categories.map((cat) => (
86
  <button
87
  key={cat}
 
 
88
  className={`px-5 py-2 rounded-full text-sm font-semibold transition-all ${
89
- cat === "All Posts"
90
  ? "bg-purple-600 text-white shadow-md"
91
  : "bg-white text-gray-600 hover:bg-gray-100 border border-gray-200"
92
  }`}
@@ -104,61 +124,74 @@ export default function BlogPage() {
104
  <div className="flex items-center gap-2 mb-8">
105
  <TrendingUp className="h-6 w-6 text-purple-600" />
106
  <h2 className="text-3xl font-bold text-gray-900">
107
- Essential Guides
 
 
108
  </h2>
109
  </div>
110
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
111
- {pillarPosts.map((post, idx) => (
112
- <a
113
- key={idx}
114
- href={`/blog/${post.slug}`}
115
- className="group bg-white rounded-2xl border border-gray-200 overflow-hidden hover:shadow-2xl transition-all duration-300 hover:-translate-y-1"
116
- >
117
- <div className="h-48 bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center relative overflow-hidden">
118
- <div className="absolute inset-0 bg-black/10 group-hover:bg-black/0 transition-colors" />
119
- <BookOpen className="h-16 w-16 text-white/90 group-hover:scale-110 transition-transform" />
120
- <div className="absolute top-4 right-4 bg-yellow-400 text-yellow-900 px-3 py-1 rounded-full text-xs font-bold">
121
- Featured
122
- </div>
123
- </div>
124
- <div className="p-6 space-y-4">
125
- <div className="flex items-center gap-2 text-sm text-gray-500">
126
- <span className="px-3 py-1 bg-purple-50 text-purple-700 rounded-full font-semibold">
127
- {post.category}
128
- </span>
129
- <span>•</span>
130
- <Clock className="h-3.5 w-3.5" />
131
- <span>{post.readTime}</span>
132
  </div>
133
- <h3 className="text-xl font-bold text-gray-900 group-hover:text-purple-600 transition-colors line-clamp-2">
134
- {post.title}
135
- </h3>
136
- <p className="text-gray-600 text-sm leading-relaxed line-clamp-3">
137
- {post.excerpt}
138
- </p>
139
- <div className="flex items-center justify-between pt-2">
140
- <span className="text-sm text-gray-400">{post.date}</span>
141
- <div className="flex items-center gap-1 text-purple-600 font-semibold text-sm group-hover:gap-2 transition-all">
142
- Read More
143
- <ArrowRight className="h-4 w-4" />
 
 
 
 
 
 
 
 
 
 
144
  </div>
145
  </div>
146
- </div>
147
- </a>
148
- ))}
149
- </div>
 
 
 
 
 
 
 
 
 
150
  </div>
151
  </section>
152
 
153
  {/* Recent Posts (Hidden if empty) */}
154
- {recentPosts.length > 0 && (
155
  <section className="px-4 pb-24">
156
  <div className="max-w-7xl mx-auto">
157
  <h2 className="text-3xl font-bold text-gray-900 mb-8">
158
  Recent Articles
159
  </h2>
160
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
161
- {recentPosts.map((post, idx) => (
162
  <a
163
  key={idx}
164
  href={`/blog/${post.slug}`}
 
1
+ import { useMemo, useState } from "react";
2
  import { BookOpen, Clock, ArrowRight, TrendingUp } from "lucide-react";
3
 
4
  export const meta = () => [
 
11
  ];
12
 
13
  export default function BlogPage() {
14
+ const [activeCategory, setActiveCategory] = useState("All Posts");
15
  const pillarPosts = [
16
  {
17
  title: "The Ultimate Guide to Downloading Twitch Clips in 2026",
 
60
  "Legal",
61
  ];
62
 
63
+ const filteredPillarPosts = useMemo(() => {
64
+ if (activeCategory === "All Posts") {
65
+ return pillarPosts;
66
+ }
67
+
68
+ return pillarPosts.filter((post) => post.category === activeCategory);
69
+ }, [activeCategory]);
70
+
71
+ const filteredRecentPosts = useMemo(() => {
72
+ if (activeCategory === "All Posts") {
73
+ return recentPosts;
74
+ }
75
+
76
+ return recentPosts.filter((post) => post.category === activeCategory);
77
+ }, [activeCategory]);
78
+
79
  return (
80
  <div className="min-h-screen bg-gradient-to-b from-purple-50 via-white to-gray-50">
81
  {/* Header */}
 
103
  {categories.map((cat) => (
104
  <button
105
  key={cat}
106
+ type="button"
107
+ onClick={() => setActiveCategory(cat)}
108
  className={`px-5 py-2 rounded-full text-sm font-semibold transition-all ${
109
+ cat === activeCategory
110
  ? "bg-purple-600 text-white shadow-md"
111
  : "bg-white text-gray-600 hover:bg-gray-100 border border-gray-200"
112
  }`}
 
124
  <div className="flex items-center gap-2 mb-8">
125
  <TrendingUp className="h-6 w-6 text-purple-600" />
126
  <h2 className="text-3xl font-bold text-gray-900">
127
+ {activeCategory === "All Posts"
128
+ ? "Essential Guides"
129
+ : `${activeCategory} Posts`}
130
  </h2>
131
  </div>
132
+ {filteredPillarPosts.length > 0 ? (
133
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
134
+ {filteredPillarPosts.map((post, idx) => (
135
+ <a
136
+ key={idx}
137
+ href={`/blog/${post.slug}`}
138
+ className="group bg-white rounded-2xl border border-gray-200 overflow-hidden hover:shadow-2xl transition-all duration-300 hover:-translate-y-1"
139
+ >
140
+ <div className="h-48 bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center relative overflow-hidden">
141
+ <div className="absolute inset-0 bg-black/10 group-hover:bg-black/0 transition-colors" />
142
+ <BookOpen className="h-16 w-16 text-white/90 group-hover:scale-110 transition-transform" />
143
+ <div className="absolute top-4 right-4 bg-yellow-400 text-yellow-900 px-3 py-1 rounded-full text-xs font-bold">
144
+ Featured
145
+ </div>
 
 
 
 
 
 
 
 
146
  </div>
147
+ <div className="p-6 space-y-4">
148
+ <div className="flex items-center gap-2 text-sm text-gray-500">
149
+ <span className="px-3 py-1 bg-purple-50 text-purple-700 rounded-full font-semibold">
150
+ {post.category}
151
+ </span>
152
+ <span>•</span>
153
+ <Clock className="h-3.5 w-3.5" />
154
+ <span>{post.readTime}</span>
155
+ </div>
156
+ <h3 className="text-xl font-bold text-gray-900 group-hover:text-purple-600 transition-colors line-clamp-2">
157
+ {post.title}
158
+ </h3>
159
+ <p className="text-gray-600 text-sm leading-relaxed line-clamp-3">
160
+ {post.excerpt}
161
+ </p>
162
+ <div className="flex items-center justify-between pt-2">
163
+ <span className="text-sm text-gray-400">{post.date}</span>
164
+ <div className="flex items-center gap-1 text-purple-600 font-semibold text-sm group-hover:gap-2 transition-all">
165
+ Read More
166
+ <ArrowRight className="h-4 w-4" />
167
+ </div>
168
  </div>
169
  </div>
170
+ </a>
171
+ ))}
172
+ </div>
173
+ ) : (
174
+ <div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center">
175
+ <h3 className="text-xl font-bold text-gray-900">
176
+ No posts yet in {activeCategory}
177
+ </h3>
178
+ <p className="mt-2 text-gray-600">
179
+ Choose another category or check back soon for new articles.
180
+ </p>
181
+ </div>
182
+ )}
183
  </div>
184
  </section>
185
 
186
  {/* Recent Posts (Hidden if empty) */}
187
+ {filteredRecentPosts.length > 0 && (
188
  <section className="px-4 pb-24">
189
  <div className="max-w-7xl mx-auto">
190
  <h2 className="text-3xl font-bold text-gray-900 mb-8">
191
  Recent Articles
192
  </h2>
193
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
194
+ {filteredRecentPosts.map((post, idx) => (
195
  <a
196
  key={idx}
197
  href={`/blog/${post.slug}`}
web/src/app/blog/twitch-clip-vs-vod-downloader/page.jsx CHANGED
@@ -31,8 +31,71 @@ export const meta = () => [
31
  ];
32
 
33
  export default function ClipVsVODDownloader() {
 
 
 
34
  return (
35
  <article className="min-h-screen bg-white">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  {/* Header */}
37
  <header className="bg-gradient-to-br from-purple-600 to-purple-800 text-white py-16 px-4">
38
  <div className="max-w-4xl mx-auto space-y-6">
 
31
  ];
32
 
33
  export default function ClipVsVODDownloader() {
34
+ const publishDate = "2026-02-12T09:00:00+00:00";
35
+ const modifyDate = new Date().toISOString();
36
+
37
  return (
38
  <article className="min-h-screen bg-white">
39
+ {/* Schema Markup */}
40
+ <script
41
+ type="application/ld+json"
42
+ dangerouslySetInnerHTML={{
43
+ __html: JSON.stringify({
44
+ "@context": "https://schema.org",
45
+ "@type": "Article",
46
+ headline: "Twitch Clip Downloader vs VOD Downloader: Which Tool Do You Need?",
47
+ description: "Understand the key differences between clip and VOD downloaders. Find out which tool best fits your needs and use cases.",
48
+ image: "https://untwitch.online/untwitch-icon.png",
49
+ author: {
50
+ "@type": "Organization",
51
+ name: "Untwitch"
52
+ },
53
+ publisher: {
54
+ "@type": "Organization",
55
+ name: "Untwitch",
56
+ logo: {
57
+ "@type": "ImageObject",
58
+ url: "https://untwitch.online/untwitch-icon.png"
59
+ }
60
+ },
61
+ datePublished: publishDate,
62
+ dateModified: modifyDate,
63
+ mainEntityOfPage: {
64
+ "@type": "WebPage",
65
+ "@id": "https://untwitch.online/blog/twitch-clip-vs-vod-downloader"
66
+ }
67
+ }),
68
+ }}
69
+ />
70
+ <script
71
+ type="application/ld+json"
72
+ dangerouslySetInnerHTML={{
73
+ __html: JSON.stringify({
74
+ "@context": "https://schema.org",
75
+ "@type": "BreadcrumbList",
76
+ itemListElement: [
77
+ {
78
+ "@type": "ListItem",
79
+ position: 1,
80
+ name: "Home",
81
+ item: "https://untwitch.online",
82
+ },
83
+ {
84
+ "@type": "ListItem",
85
+ position: 2,
86
+ name: "Blog",
87
+ item: "https://untwitch.online/blog",
88
+ },
89
+ {
90
+ "@type": "ListItem",
91
+ position: 3,
92
+ name: "Clip vs VOD Downloader",
93
+ item: "https://untwitch.online/blog/twitch-clip-vs-vod-downloader",
94
+ },
95
+ ],
96
+ }),
97
+ }}
98
+ />
99
  {/* Header */}
100
  <header className="bg-gradient-to-br from-purple-600 to-purple-800 text-white py-16 px-4">
101
  <div className="max-w-4xl mx-auto space-y-6">
web/src/app/contact/page.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import { Mail, Send, CheckCircle2, MessageSquare, Clock, Globe } from "lucide-react";
2
  import { useState } from "react";
3
 
4
  export const meta = () => [
@@ -19,6 +19,30 @@ export default function ContactPage() {
19
 
20
  return (
21
  <div className="max-w-4xl mx-auto px-4 py-20 space-y-12">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  <div className="text-center space-y-4">
23
  <h1 className="text-4xl md:text-5xl font-extrabold text-gray-900 tracking-tight">
24
  Contact Us
@@ -29,6 +53,14 @@ export default function ContactPage() {
29
  </p>
30
  </div>
31
 
 
 
 
 
 
 
 
 
32
  <div className="grid grid-cols-1 md:grid-cols-2 gap-12">
33
  <div className="space-y-8">
34
  <div className="bg-purple-50 p-8 rounded-3xl space-y-6">
 
1
+ import { Mail, Send, CheckCircle2, MessageSquare, Clock, Globe, ChevronRight } from "lucide-react";
2
  import { useState } from "react";
3
 
4
  export const meta = () => [
 
19
 
20
  return (
21
  <div className="max-w-4xl mx-auto px-4 py-20 space-y-12">
22
+ <script
23
+ type="application/ld+json"
24
+ dangerouslySetInnerHTML={{
25
+ __html: JSON.stringify({
26
+ "@context": "https://schema.org",
27
+ "@type": "BreadcrumbList",
28
+ itemListElement: [
29
+ {
30
+ "@type": "ListItem",
31
+ position: 1,
32
+ name: "Home",
33
+ item: "https://untwitch.online",
34
+ },
35
+ {
36
+ "@type": "ListItem",
37
+ position: 2,
38
+ name: "Contact",
39
+ item: "https://untwitch.online/contact",
40
+ },
41
+ ],
42
+ }),
43
+ }}
44
+ />
45
+
46
  <div className="text-center space-y-4">
47
  <h1 className="text-4xl md:text-5xl font-extrabold text-gray-900 tracking-tight">
48
  Contact Us
 
53
  </p>
54
  </div>
55
 
56
+ <nav className="flex items-center justify-center gap-2 text-sm text-gray-400">
57
+ <a href="/" className="hover:text-purple-600 transition-colors">
58
+ Home
59
+ </a>
60
+ <ChevronRight className="h-3 w-3" />
61
+ <span className="text-gray-900 font-medium">Contact</span>
62
+ </nav>
63
+
64
  <div className="grid grid-cols-1 md:grid-cols-2 gap-12">
65
  <div className="space-y-8">
66
  <div className="bg-purple-50 p-8 rounded-3xl space-y-6">
web/src/app/features/page.jsx CHANGED
@@ -8,6 +8,7 @@ import {
8
  Globe,
9
  Lock,
10
  Cpu,
 
11
  } from "lucide-react";
12
 
13
  export const meta = () => [
@@ -58,6 +59,30 @@ export default function FeaturesPage() {
58
 
59
  return (
60
  <div className="max-w-7xl mx-auto px-4 py-20 space-y-24">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  <div className="text-center space-y-4">
62
  <h1 className="text-4xl md:text-6xl font-extrabold text-gray-900 tracking-tight">
63
  Powerful Features, <br />
@@ -69,6 +94,14 @@ export default function FeaturesPage() {
69
  </p>
70
  </div>
71
 
 
 
 
 
 
 
 
 
72
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
73
  {features.map((f, idx) => (
74
  <div
 
8
  Globe,
9
  Lock,
10
  Cpu,
11
+ ChevronRight,
12
  } from "lucide-react";
13
 
14
  export const meta = () => [
 
59
 
60
  return (
61
  <div className="max-w-7xl mx-auto px-4 py-20 space-y-24">
62
+ <script
63
+ type="application/ld+json"
64
+ dangerouslySetInnerHTML={{
65
+ __html: JSON.stringify({
66
+ "@context": "https://schema.org",
67
+ "@type": "BreadcrumbList",
68
+ itemListElement: [
69
+ {
70
+ "@type": "ListItem",
71
+ position: 1,
72
+ name: "Home",
73
+ item: "https://untwitch.online",
74
+ },
75
+ {
76
+ "@type": "ListItem",
77
+ position: 2,
78
+ name: "Features",
79
+ item: "https://untwitch.online/features",
80
+ },
81
+ ],
82
+ }),
83
+ }}
84
+ />
85
+
86
  <div className="text-center space-y-4">
87
  <h1 className="text-4xl md:text-6xl font-extrabold text-gray-900 tracking-tight">
88
  Powerful Features, <br />
 
94
  </p>
95
  </div>
96
 
97
+ <nav className="flex items-center justify-center gap-2 text-sm text-gray-400">
98
+ <a href="/" className="hover:text-purple-600 transition-colors">
99
+ Home
100
+ </a>
101
+ <ChevronRight className="h-3 w-3" />
102
+ <span className="text-gray-900 font-medium">Features</span>
103
+ </nav>
104
+
105
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
106
  {features.map((f, idx) => (
107
  <div
web/src/app/robots.txt/{route.js → route.server.js} RENAMED
File without changes
web/src/app/root.tsx CHANGED
@@ -89,7 +89,17 @@ export const meta: Route.MetaFunction = () => [
89
  { name: 'twitter:image', content: '/untwitch-icon.png' },
90
  ];
91
 
92
- export const links = () => [];
 
 
 
 
 
 
 
 
 
 
93
 
94
  if (globalThis.window && globalThis.window !== undefined) {
95
  globalThis.window.fetch = fetch;
@@ -492,11 +502,9 @@ export function Layout({ children }: { children: ReactNode }) {
492
  <meta name="viewport" content="width=device-width, initial-scale=1" />
493
  <Meta />
494
  <Links />
495
- <link
496
- rel="manifest"
497
- href="/manifest.json"
498
- crossOrigin="use-credentials"
499
- />
500
  <meta name="msapplication-config" content="/browserconfig.xml" />
501
  <meta name="msapplication-TileImage" content="/untwitch-icon.png" />
502
  <meta name="msapplication-TileColor" content="#7C3AED" />
@@ -580,7 +588,6 @@ export function Layout({ children }: { children: ReactNode }) {
580
  }}
581
  />
582
  <script type="module" src="/src/__create/dev-error-overlay.js"></script>
583
- <link rel="icon" href="/src/__create/favicon.png" />
584
  {LoadFontsSSR ? <LoadFontsSSR /> : null}
585
  </head>
586
  <body>
 
89
  { name: 'twitter:image', content: '/untwitch-icon.png' },
90
  ];
91
 
92
+ export const links: Route.LinksFunction = () => [
93
+ { rel: 'canonical', href: 'https://untwitch.online/' },
94
+ { rel: 'icon', href: '/favicon.ico', sizes: 'any' },
95
+ { rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' },
96
+ { rel: 'apple-touch-icon', href: '/untwitch-icon.png' },
97
+ {
98
+ rel: 'manifest',
99
+ href: '/manifest.json',
100
+ crossOrigin: 'use-credentials',
101
+ },
102
+ ];
103
 
104
  if (globalThis.window && globalThis.window !== undefined) {
105
  globalThis.window.fetch = fetch;
 
502
  <meta name="viewport" content="width=device-width, initial-scale=1" />
503
  <Meta />
504
  <Links />
505
+ <link rel="icon" href="/favicon.ico" type="image/x-icon" />
506
+ <link rel="icon" href="/favicon.png" type="image/png" />
507
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
 
 
508
  <meta name="msapplication-config" content="/browserconfig.xml" />
509
  <meta name="msapplication-TileImage" content="/untwitch-icon.png" />
510
  <meta name="msapplication-TileColor" content="#7C3AED" />
 
588
  }}
589
  />
590
  <script type="module" src="/src/__create/dev-error-overlay.js"></script>
 
591
  {LoadFontsSSR ? <LoadFontsSSR /> : null}
592
  </head>
593
  <body>
web/src/app/sitemap.xml/{route.js → route.server.js} RENAMED
File without changes
web/vercel.json CHANGED
@@ -18,7 +18,7 @@
18
  "destination": "/api/index"
19
  },
20
  {
21
- "source": "/((?!assets/|favicon|robots\\.txt|sitemap\\.xml|manifest\\.json|browserconfig\\.xml).*)",
22
  "destination": "/api/index"
23
  }
24
  ],
 
18
  "destination": "/api/index"
19
  },
20
  {
21
+ "source": "/((?!assets/|robots\\.txt|sitemap\\.xml|manifest\\.json|browserconfig\\.xml).*)",
22
  "destination": "/api/index"
23
  }
24
  ],