initial commit
This commit is contained in:
parent
24415d4a20
commit
c81c3f4d11
@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
test-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@ -20,6 +20,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Build app
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy to Cloudflare Workers
|
||||
run: npx wrangler deploy --minify
|
||||
env:
|
||||
|
||||
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.npm/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
.wrangler/
|
||||
.hono/
|
||||
|
||||
# Database / Storage
|
||||
.wrangler/state/
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
|
||||
# Environment variables & Secrets
|
||||
.env
|
||||
.env.*
|
||||
.dev.vars
|
||||
.wrangler/config/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# IDE / OS Files
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
@ -7,7 +7,7 @@ export default defineConfig({
|
||||
driver: 'd1-http',
|
||||
dbCredentials: {
|
||||
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
|
||||
databaseId: 'test-db-id',
|
||||
databaseId: '1f2607d4-7076-4854-a34d-90357c600726',
|
||||
token: process.env.CLOUDFLARE_API_TOKEN!,
|
||||
},
|
||||
});
|
||||
|
||||
4
drizzle/0000_numerous_stryfe.sql
Normal file
4
drizzle/0000_numerous_stryfe.sql
Normal file
@ -0,0 +1,4 @@
|
||||
CREATE TABLE `test` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL
|
||||
);
|
||||
35
drizzle/0001_eminent_tyger_tiger.sql
Normal file
35
drizzle/0001_eminent_tyger_tiger.sql
Normal file
@ -0,0 +1,35 @@
|
||||
CREATE TABLE `follows` (
|
||||
`follower_id` integer NOT NULL,
|
||||
`following_id` integer NOT NULL,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY(`follower_id`, `following_id`),
|
||||
FOREIGN KEY (`follower_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`following_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `likes` (
|
||||
`user_id` integer NOT NULL,
|
||||
`post_id` integer NOT NULL,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY(`user_id`, `post_id`),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`post_id`) REFERENCES `posts`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `posts` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`username` text NOT NULL,
|
||||
`password_hash` text NOT NULL,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);--> statement-breakpoint
|
||||
DROP TABLE `test`;
|
||||
42
drizzle/meta/0000_snapshot.json
Normal file
42
drizzle/meta/0000_snapshot.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "613fcd4f-28f5-485c-affa-5e04815a2b0d",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"test": {
|
||||
"name": "test",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
252
drizzle/meta/0001_snapshot.json
Normal file
252
drizzle/meta/0001_snapshot.json
Normal file
@ -0,0 +1,252 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "49178e52-ef6b-4d62-b773-b4acb26eae3f",
|
||||
"prevId": "613fcd4f-28f5-485c-affa-5e04815a2b0d",
|
||||
"tables": {
|
||||
"follows": {
|
||||
"name": "follows",
|
||||
"columns": {
|
||||
"follower_id": {
|
||||
"name": "follower_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"following_id": {
|
||||
"name": "following_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"follows_follower_id_users_id_fk": {
|
||||
"name": "follows_follower_id_users_id_fk",
|
||||
"tableFrom": "follows",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"follower_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"follows_following_id_users_id_fk": {
|
||||
"name": "follows_following_id_users_id_fk",
|
||||
"tableFrom": "follows",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"following_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"follows_follower_id_following_id_pk": {
|
||||
"columns": [
|
||||
"follower_id",
|
||||
"following_id"
|
||||
],
|
||||
"name": "follows_follower_id_following_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"likes": {
|
||||
"name": "likes",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"post_id": {
|
||||
"name": "post_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"likes_user_id_users_id_fk": {
|
||||
"name": "likes_user_id_users_id_fk",
|
||||
"tableFrom": "likes",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"likes_post_id_posts_id_fk": {
|
||||
"name": "likes_post_id_posts_id_fk",
|
||||
"tableFrom": "likes",
|
||||
"tableTo": "posts",
|
||||
"columnsFrom": [
|
||||
"post_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"likes_user_id_post_id_pk": {
|
||||
"columns": [
|
||||
"user_id",
|
||||
"post_id"
|
||||
],
|
||||
"name": "likes_user_id_post_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"posts": {
|
||||
"name": "posts",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"posts_user_id_users_id_fk": {
|
||||
"name": "posts_user_id_users_id_fk",
|
||||
"tableFrom": "posts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
20
drizzle/meta/_journal.json
Normal file
20
drizzle/meta/_journal.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1772525981058,
|
||||
"tag": "0000_numerous_stryfe",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1772526212686,
|
||||
"tag": "0001_eminent_tyger_tiger",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
1384
node_modules/.package-lock.json
generated
vendored
1384
node_modules/.package-lock.json
generated
vendored
File diff suppressed because it is too large
Load Diff
1340
package-lock.json
generated
1340
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@ -4,7 +4,10 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"deploy": "wrangler deploy --minify",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@ -14,16 +17,24 @@
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"hono": "^4.12.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260305.0",
|
||||
"@hono/vite-build": "^1.9.3",
|
||||
"@hono/vite-dev-server": "^0.25.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"jsdom": "^28.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18",
|
||||
"wrangler": "^4.69.0"
|
||||
}
|
||||
}
|
||||
|
||||
24
src/App.test.tsx
Normal file
24
src/App.test.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import App from './App'
|
||||
|
||||
describe('Frontend App', () => {
|
||||
it('renders loading state and then data', async () => {
|
||||
// Mock the global fetch function
|
||||
const mockData = [{ id: 1, name: 'Test Entry' }]
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
json: () => Promise.resolve(mockData),
|
||||
})
|
||||
|
||||
render(<App />)
|
||||
|
||||
// Check for title
|
||||
expect(screen.getByText(/Full-stack Cloudflare Workers/i)).toBeInTheDocument()
|
||||
|
||||
// Wait for data to be displayed
|
||||
await waitFor(() => {
|
||||
const pre = screen.getByText(/Test Entry/i)
|
||||
expect(pre).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
170
src/App.tsx
170
src/App.tsx
@ -1,20 +1,170 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
const App = () => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const API_BASE = '/api';
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/test')
|
||||
.then((res) => res.json())
|
||||
.then((json) => setData(json));
|
||||
}, []);
|
||||
const Login = ({ setAuth }: any) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogin = async (e: any) => {
|
||||
e.preventDefault();
|
||||
const res = await fetch(`${API_BASE}/login`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
setAuth(data.token);
|
||||
navigate('/');
|
||||
} else {
|
||||
alert('Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Full-stack Cloudflare Workers</h1>
|
||||
<pre>{JSON.stringify(data, null, 2)}</pre>
|
||||
<div className="card">
|
||||
<h2>Login</h2>
|
||||
<form onSubmit={handleLogin}>
|
||||
<input placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} /><br/><br/>
|
||||
<input placeholder="Password" type="password" value={password} onChange={e => setPassword(e.target.value)} /><br/><br/>
|
||||
<button className="btn" type="submit">Login</button>
|
||||
</form>
|
||||
<p>Don't have an account? <Link to="/signup">Signup</Link></p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Signup = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSignup = async (e: any) => {
|
||||
e.preventDefault();
|
||||
const res = await fetch(`${API_BASE}/signup`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
if (res.ok) {
|
||||
alert('Signup success! Please login.');
|
||||
navigate('/login');
|
||||
} else {
|
||||
alert('Signup failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Signup</h2>
|
||||
<form onSubmit={handleSignup}>
|
||||
<input placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} /><br/><br/>
|
||||
<input placeholder="Password" type="password" value={password} onChange={e => setPassword(e.target.value)} /><br/><br/>
|
||||
<button className="btn" type="submit">Signup</button>
|
||||
</form>
|
||||
<p>Already have an account? <Link to="/login">Login</Link></p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Feed = ({ token }: { token: string }) => {
|
||||
const [posts, setPosts] = useState<any[]>([]);
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
const fetchFeed = async () => {
|
||||
const res = await fetch(`${API_BASE}/protected/feed`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
setPosts(data);
|
||||
};
|
||||
|
||||
const handlePost = async (e: any) => {
|
||||
e.preventDefault();
|
||||
await fetch(`${API_BASE}/protected/posts`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }
|
||||
});
|
||||
setContent('');
|
||||
fetchFeed();
|
||||
};
|
||||
|
||||
const handleLike = async (id: number) => {
|
||||
await fetch(`${API_BASE}/protected/like/${id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
alert('Liked!');
|
||||
};
|
||||
|
||||
const handleFollow = async (userId: number) => {
|
||||
await fetch(`${API_BASE}/protected/follow/${userId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
alert('Followed!');
|
||||
fetchFeed();
|
||||
};
|
||||
|
||||
useEffect(() => { fetchFeed(); }, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<h3>What's on your mind?</h3>
|
||||
<form onSubmit={handlePost}>
|
||||
<textarea style={{width:'100%', marginBottom:'10px'}} value={content} onChange={e => setContent(e.target.value)} />
|
||||
<button className="btn" type="submit">Post</button>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
{posts.map(post => (
|
||||
<div key={post.id} className="card">
|
||||
<strong>@{post.username}</strong>
|
||||
<p>{post.content}</p>
|
||||
<button className="btn" onClick={() => handleLike(post.id)}>Like</button>
|
||||
<button className="btn" style={{marginLeft:'10px', background:'#6c757d'}} onClick={() => handleFollow(post.userId)}>Follow</button>
|
||||
<small style={{display:'block', marginTop:'10px', color:'#777'}}>{post.createdAt}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const [token, setToken] = useState(localStorage.getItem('token'));
|
||||
|
||||
const logout = () => {
|
||||
localStorage.clear();
|
||||
setToken(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="container">
|
||||
<nav className="nav">
|
||||
<Link to="/">Feed</Link> |
|
||||
{!token ? (
|
||||
<> <Link to="/login">Login</Link> | <Link to="/signup">Signup</Link> </>
|
||||
) : (
|
||||
<button onClick={logout} style={{marginLeft:'10px'}}>Logout</button>
|
||||
)}
|
||||
</nav>
|
||||
<Routes>
|
||||
<Route path="/" element={token ? <Feed token={token} /> : <Link to="/login">Please Login</Link>} />
|
||||
<Route path="/login" element={<Login setAuth={setToken} />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@ -1,6 +1,36 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const testTable = sqliteTable('test', {
|
||||
export const usersTable = sqliteTable('users', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull(),
|
||||
username: text('username').notNull().unique(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const postsTable = sqliteTable('posts', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: integer('user_id').notNull().references(() => usersTable.id),
|
||||
content: text('content').notNull(),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const followsTable = sqliteTable('follows', {
|
||||
followerId: integer('follower_id').notNull().references(() => usersTable.id),
|
||||
followingId: integer('following_id').notNull().references(() => usersTable.id),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
}, (table) => {
|
||||
return {
|
||||
pk: primaryKey({ columns: [table.followerId, table.followingId] }),
|
||||
};
|
||||
});
|
||||
|
||||
export const likesTable = sqliteTable('likes', {
|
||||
userId: integer('user_id').notNull().references(() => usersTable.id),
|
||||
postId: integer('post_id').notNull().references(() => postsTable.id),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
}, (table) => {
|
||||
return {
|
||||
pk: primaryKey({ columns: [table.userId, table.postId] }),
|
||||
};
|
||||
});
|
||||
|
||||
54
src/index.test.ts
Normal file
54
src/index.test.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import app from './index'
|
||||
|
||||
describe('Backend API', () => {
|
||||
it('GET /api/test should return JSON', async () => {
|
||||
// Mock the environment with a fake D1 database
|
||||
const mockData = [{ id: 1, name: 'Test Entry' }]
|
||||
const mockStmt = {
|
||||
bind: vi.fn().mockReturnThis(),
|
||||
all: vi.fn().mockResolvedValue({ results: mockData, success: true }),
|
||||
raw: vi.fn().mockResolvedValue(mockData),
|
||||
run: vi.fn().mockResolvedValue({ success: true }),
|
||||
}
|
||||
const env = {
|
||||
DB: {
|
||||
prepare: vi.fn().mockReturnValue(mockStmt)
|
||||
}
|
||||
}
|
||||
// Drizzle-D1 requires a specific structure for mocked responses if it uses metadata
|
||||
// For this simple case, we'll bypass the ORM and return direct json if needed,
|
||||
// but let's try to fix the mock for drizzle.
|
||||
|
||||
const res = await app.request('/api/test', {}, env as any)
|
||||
if (res.status === 500) {
|
||||
const err = await res.text()
|
||||
console.error(err)
|
||||
}
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body).toEqual([{ id: 1, name: 'Test Entry' }])
|
||||
})
|
||||
|
||||
it('GET /api/raw-test should return JSON from raw D1', async () => {
|
||||
const mockData = [{ id: 1, name: 'Test Entry' }]
|
||||
const env = {
|
||||
DB: {
|
||||
prepare: vi.fn().mockReturnValue({
|
||||
all: vi.fn().mockResolvedValue({ results: mockData })
|
||||
})
|
||||
}
|
||||
}
|
||||
const res = await app.request('/api/raw-test', {}, env as any)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('GET / should return HTML with root div', async () => {
|
||||
const res = await app.request('/')
|
||||
expect(res.status).toBe(200)
|
||||
const text = await res.text()
|
||||
expect(text).toContain('<div id="root"></div>')
|
||||
})
|
||||
})
|
||||
148
src/index.ts
148
src/index.ts
@ -1,20 +1,137 @@
|
||||
import { Hono } from 'hono'
|
||||
import { jwt, sign, verify } from 'hono/jwt'
|
||||
import { drizzle } from 'drizzle-orm/d1'
|
||||
import { testTable } from './db/schema'
|
||||
import { eq, and, inArray, desc } from 'drizzle-orm'
|
||||
import { usersTable, postsTable, followsTable, likesTable } from './db/schema'
|
||||
|
||||
type Bindings = {
|
||||
DB: D1Database
|
||||
JWT_SECRET: string
|
||||
}
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>()
|
||||
|
||||
app.get('/api/test', async (c) => {
|
||||
const db = drizzle(c.env.DB)
|
||||
const result = await db.select().from(testTable).all()
|
||||
return c.json(result)
|
||||
// Middleware for JWT protection on some routes
|
||||
app.use('/api/protected/*', async (c, next) => {
|
||||
const jwtMiddleware = jwt({
|
||||
secret: c.env.JWT_SECRET || 'supersecret',
|
||||
alg: 'HS256'
|
||||
})
|
||||
return jwtMiddleware(c, next)
|
||||
})
|
||||
|
||||
// For static serving (to be built by Vite)
|
||||
// --- AUTH ---
|
||||
app.post('/api/signup', async (c) => {
|
||||
const { username, password } = await c.req.json()
|
||||
const db = drizzle(c.env.DB)
|
||||
|
||||
try {
|
||||
const result = await db.insert(usersTable).values({
|
||||
username,
|
||||
passwordHash: password, // In a real app, hash this!
|
||||
}).returning()
|
||||
|
||||
return c.json({ success: true, user: result[0] })
|
||||
} catch (e) {
|
||||
console.error('Signup error:', e)
|
||||
return c.json({ error: 'Username taken or database error', details: String(e) }, 400)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/login', async (c) => {
|
||||
const { username, password } = await c.req.json()
|
||||
const db = drizzle(c.env.DB)
|
||||
|
||||
const user = await db.select().from(usersTable)
|
||||
.where(and(eq(usersTable.username, username), eq(usersTable.passwordHash, password)))
|
||||
.get()
|
||||
|
||||
if (!user) return c.json({ error: 'Invalid credentials' }, 401)
|
||||
|
||||
const token = await sign({ id: user.id, username: user.username }, c.env.JWT_SECRET || 'supersecret', 'HS256')
|
||||
return c.json({ token, user })
|
||||
})
|
||||
|
||||
// --- POSTS & FEED ---
|
||||
app.get('/api/protected/feed', async (c) => {
|
||||
const payload = c.get('jwtPayload')
|
||||
const db = drizzle(c.env.DB)
|
||||
|
||||
try {
|
||||
// Get followed users
|
||||
const followed = await db.select({ id: followsTable.followingId })
|
||||
.from(followsTable)
|
||||
.where(eq(followsTable.followerId, payload.id))
|
||||
.all()
|
||||
|
||||
const followedIds = [payload.id, ...followed.map(f => f.id)]
|
||||
|
||||
const feed = await db.select({
|
||||
id: postsTable.id,
|
||||
content: postsTable.content,
|
||||
createdAt: postsTable.createdAt,
|
||||
username: usersTable.username,
|
||||
userId: usersTable.id
|
||||
})
|
||||
.from(postsTable)
|
||||
.leftJoin(usersTable, eq(postsTable.userId, usersTable.id))
|
||||
.where(inArray(postsTable.userId, followedIds))
|
||||
.orderBy(desc(postsTable.createdAt))
|
||||
.all()
|
||||
|
||||
return c.json(feed)
|
||||
} catch (e) {
|
||||
console.error('Feed error:', e)
|
||||
return c.json({ error: 'Failed to fetch feed', details: String(e) }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/protected/posts', async (c) => {
|
||||
const payload = c.get('jwtPayload')
|
||||
const { content } = await c.req.json()
|
||||
const db = drizzle(c.env.DB)
|
||||
|
||||
try {
|
||||
const result = await db.insert(postsTable).values({
|
||||
userId: payload.id,
|
||||
content,
|
||||
}).returning()
|
||||
|
||||
return c.json(result[0])
|
||||
} catch (e) {
|
||||
console.error('Post creation error:', e)
|
||||
return c.json({ error: 'Failed to create post', details: String(e) }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
// --- FOLLOW & LIKE ---
|
||||
app.post('/api/protected/follow/:id', async (c) => {
|
||||
const payload = c.get('jwtPayload')
|
||||
const targetId = parseInt(c.req.param('id'))
|
||||
const db = drizzle(c.env.DB)
|
||||
|
||||
await db.insert(followsTable).values({
|
||||
followerId: payload.id,
|
||||
followingId: targetId,
|
||||
}).run()
|
||||
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
app.post('/api/protected/like/:id', async (c) => {
|
||||
const payload = c.get('jwtPayload')
|
||||
const postId = parseInt(c.req.param('id'))
|
||||
const db = drizzle(c.env.DB)
|
||||
|
||||
await db.insert(likesTable).values({
|
||||
userId: payload.id,
|
||||
postId,
|
||||
}).run()
|
||||
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// Serve React App
|
||||
app.get('*', async (c) => {
|
||||
return c.html(`
|
||||
<!DOCTYPE html>
|
||||
@ -22,7 +139,24 @@ app.get('*', async (c) => {
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Full-stack Worker</title>
|
||||
<title>Social Media App</title>
|
||||
${import.meta.env?.PROD ? '' : `
|
||||
<script type="module">
|
||||
import RefreshRuntime from "/@react-refresh"
|
||||
RefreshRuntime.injectIntoGlobalHook(window)
|
||||
window.$RefreshReg$ = () => {}
|
||||
window.$RefreshSig$ = () => (type) => type
|
||||
window.__reactRefreshLog = () => {}
|
||||
</script>
|
||||
<script type="module" src="/@vite/client"></script>
|
||||
`}
|
||||
<style>
|
||||
body { font-family: sans-serif; background: #f0f2f5; margin: 0; padding: 20px; }
|
||||
.container { max-width: 600px; margin: auto; }
|
||||
.card { background: white; padding: 15px; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 1px 2px rgba(0,0,0,0.1); }
|
||||
.btn { background: #007bff; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; }
|
||||
.nav { margin-bottom: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
11
src/test-setup.ts
Normal file
11
src/test-setup.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Mock the Cloudflare D1 environment
|
||||
vi.stubGlobal('D1Database', class {
|
||||
prepare() { return this }
|
||||
bind() { return this }
|
||||
all() { return Promise.resolve({ results: [] }) }
|
||||
first() { return Promise.resolve(null) }
|
||||
run() { return Promise.resolve({ success: true }) }
|
||||
})
|
||||
14
vite.config.ts
Normal file
14
vite.config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import devServer from '@hono/vite-dev-server'
|
||||
import cloudflareAdapter from '@hono/vite-dev-server/cloudflare'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
devServer({
|
||||
entry: 'src/index.ts', // Hono entry point
|
||||
adapter: cloudflareAdapter,
|
||||
}),
|
||||
],
|
||||
})
|
||||
11
vitest.config.ts
Normal file
11
vitest.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: './src/test-setup.ts',
|
||||
},
|
||||
})
|
||||
@ -5,4 +5,5 @@ main = "src/index.ts"
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "test-db"
|
||||
database_id = "test-db-id"
|
||||
database_id = "1f2607d4-7076-4854-a34d-90357c600726"
|
||||
migrations_dir = "drizzle"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user