SaleemFiverr commited on
Commit ·
1108f91
1
Parent(s): bca2b50
Fix favicon and enhance SEO with standard Google practices
Browse files- .gitignore +28 -0
- .nvmrc +1 -0
- DEPLOYMENT.md +98 -0
- Dockerfile +15 -12
- vercel.json +40 -0
- web/__create/index.ts +134 -0
- web/public/favicon.svg +0 -0
- web/src/app/blog/download-twitch-vods-complete-guide/page.jsx +63 -0
- web/src/app/blog/page.jsx +74 -41
- web/src/app/blog/twitch-clip-vs-vod-downloader/page.jsx +63 -0
- web/src/app/contact/page.jsx +33 -1
- web/src/app/features/page.jsx +33 -0
- web/src/app/robots.txt/{route.js → route.server.js} +0 -0
- web/src/app/root.tsx +14 -7
- web/src/app/sitemap.xml/{route.js → route.server.js} +0 -0
- web/vercel.json +1 -1
.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
|
| 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 |
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
|
| 11 |
# Set the working directory
|
| 12 |
WORKDIR /app
|
| 13 |
|
| 14 |
-
# Copy
|
| 15 |
COPY package*.json ./
|
| 16 |
COPY web/package*.json ./web/
|
| 17 |
|
| 18 |
-
# Install dependencies (
|
| 19 |
-
RUN npm install --
|
| 20 |
|
| 21 |
# Copy the rest of the application code
|
| 22 |
COPY . .
|
| 23 |
|
| 24 |
-
#
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
# Use environment variable for port (Hugging Face uses 7860)
|
| 28 |
ENV NODE_ENV=production
|
| 29 |
ENV PORT=7860
|
| 30 |
|
| 31 |
-
#
|
|
|
|
|
|
|
|
|
|
| 32 |
EXPOSE 7860
|
| 33 |
|
| 34 |
-
# Start the application
|
| 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 ===
|
| 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 |
-
|
|
|
|
|
|
|
| 108 |
</h2>
|
| 109 |
</div>
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
<div className="
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 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 |
-
<
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
<div
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
</div>
|
| 145 |
</div>
|
| 146 |
-
</
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
</div>
|
| 151 |
</section>
|
| 152 |
|
| 153 |
{/* Recent Posts (Hidden if empty) */}
|
| 154 |
-
{
|
| 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 |
-
{
|
| 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 |
-
|
| 497 |
-
|
| 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/|
|
| 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 |
],
|