feat: add photos challenges
This commit is contained in:
parent
26039148eb
commit
cd79fe01d9
22
photos/README.md
Normal file
22
photos/README.md
Normal file
@ -0,0 +1,22 @@
|
||||
# Photos: The next big social media platform
|
||||
|
||||
This challenge contains 2 flags. The description for both flags can be the same.
|
||||
|
||||
## Text
|
||||
|
||||
I have built my own social media platform!
|
||||
I really feel like there is a gap in the market for new social media apps.
|
||||
My idea is super original: it's a website were people can upload and share photos.
|
||||
|
||||
I'm still missing some features, but you can have exclusive beta access :)
|
||||
Have fun!
|
||||
|
||||
## Files
|
||||
|
||||
- Source code: [photos-source-code.zip](./photos-source-code.zip)
|
||||
|
||||
**ONLY GIVE THE ZIP FILE** The other files and directories may contain actual flags.
|
||||
|
||||
## How to deploy
|
||||
|
||||
N.A.
|
31
photos/SOLUTION-1.md
Normal file
31
photos/SOLUTION-1.md
Normal file
@ -0,0 +1,31 @@
|
||||
## Difficulty
|
||||
|
||||
Medium
|
||||
|
||||
## Category
|
||||
|
||||
Web
|
||||
|
||||
## How to solve
|
||||
|
||||
The goal of this challenge is to see the photo uploaded by the administrator.
|
||||
It should be clear, given the source code, that a photo is automatically uploaded as part of the challenge.
|
||||
|
||||
When analyzing the communication between the frontend and the backend through the DevTools, or by inspecting the source code, you can see that there is no check if a user likes or un-likes a private photo they do not have access to. The ability to like private photos of other users is not a risk by itself, but the like endpoint also returns the updated photo.
|
||||
|
||||
Liking the adminstrator's photo (which has id 1) will return the caption of the photo, containing the first flag.
|
||||
The following curl command returns the flag in the response:
|
||||
|
||||
> [!NOTE]
|
||||
> You will need to create an account to use the /like route.
|
||||
> To use the command below, replace <YOUR_COOKIE> with your session cookie.
|
||||
|
||||
```bash
|
||||
curl --cookie "connect.sid=<YOUR_COOKIE>" --request POST http://localhost:3000/api/photos/1/like
|
||||
```
|
||||
|
||||
## Flag
|
||||
|
||||
```
|
||||
IGCTF{jUsT-a-sma11-data-l3ak}
|
||||
```
|
24
photos/SOLUTION-2.md
Normal file
24
photos/SOLUTION-2.md
Normal file
@ -0,0 +1,24 @@
|
||||
## Difficulty
|
||||
|
||||
Hard
|
||||
|
||||
## Category
|
||||
|
||||
Web
|
||||
|
||||
## How to solve
|
||||
|
||||
The goal of this challenge is to see the photo uploaded by the administrator.
|
||||
It should be clear, given the source code, that a photo is automatically uploaded as part of the challenge.
|
||||
|
||||
After finding the [first challenge](./SOLUTION-1.md), we have access to the caption of the administrator's photo, but not to the image itself. Unfortunately, the API route for getting the image itself checks for the correct permissions.
|
||||
|
||||
However, the frontend uses Next.js' `Image` component for showing images. This automatically optimizes and caches images. The application is served using nginx, which is configured in this case to "cache all the static assets". However, Next.js' static assets are served from `/_next/static/`, and not `/_next/`, which is also used by the image component at `/_next/image`. We can use this endpoint to get a cached version of the administrator's image, bypassing the authentication checks of the backend. You can also see this by playing around in the DevTools and seeing that the sources of the images go to `/_next/image` and not `/api/photo/...`.
|
||||
|
||||
To get the image associated with the flag, visit `http://localhost:3000/_next/image?url=%2Fapi%2Fphotos%2F1.jpeg`
|
||||
|
||||
## Flag
|
||||
|
||||
```
|
||||
IGCTF{a-bIt-t00-0pt1mized}
|
||||
```
|
2
photos/backend/.env.development
Normal file
2
photos/backend/.env.development
Normal file
@ -0,0 +1,2 @@
|
||||
DB_FILE_NAME=file:local.db
|
||||
SESSION_SECRET=secret
|
197
photos/backend/.gitignore
vendored
Normal file
197
photos/backend/.gitignore
vendored
Normal file
@ -0,0 +1,197 @@
|
||||
### Generated by gibo (https://github.com/simonwhitaker/gibo)
|
||||
### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Global/macOS.gitignore
|
||||
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
### Generated by gibo (https://github.com/simonwhitaker/gibo)
|
||||
### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Global/VisualStudioCode.gitignore
|
||||
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
### Generated by gibo (https://github.com/simonwhitaker/gibo)
|
||||
### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Node.gitignore
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
### Generated by gibo (https://github.com/simonwhitaker/gibo)
|
||||
### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Global/Linux.gitignore
|
||||
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
data/
|
||||
local.db
|
44
photos/backend/Dockerfile
Normal file
44
photos/backend/Dockerfile
Normal file
@ -0,0 +1,44 @@
|
||||
|
||||
# https://www.tomray.dev/nestjs-docker-production#conclusion
|
||||
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY --chown=app:nodejs package*.json ./
|
||||
COPY --chown=app:nodejs pnpm-lock.yaml ./
|
||||
|
||||
FROM base AS prod-deps
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
|
||||
|
||||
FROM base AS build
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
|
||||
COPY --chown=app:nodejs src ./src
|
||||
COPY --chown=app:nodejs tsconfig.json drizzle.config.ts .
|
||||
|
||||
RUN pnpm run build
|
||||
|
||||
FROM base
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 app
|
||||
|
||||
COPY --chown=app:nodejs package*.json ./
|
||||
COPY --chown=app:nodejs pnpm-lock.yaml ./
|
||||
COPY --chown=app:nodejs --from=build /usr/src/app/node_modules ./node_modules
|
||||
COPY --chown=app:nodejs --from=build /usr/src/app/dist ./dist
|
||||
COPY --chown=app:nodejs drizzle ./drizzle
|
||||
RUN chown app:nodejs /usr/src/app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
USER app
|
||||
|
||||
RUN mkdir data
|
||||
|
||||
ENTRYPOINT ["node", "dist/src/app.js"]
|
12
photos/backend/drizzle.config.ts
Normal file
12
photos/backend/drizzle.config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./drizzle",
|
||||
schema: "./src/db/schema.ts",
|
||||
dialect: "sqlite",
|
||||
dbCredentials: {
|
||||
url: process.env.DB_FILE_NAME!,
|
||||
},
|
||||
casing: "snake_case",
|
||||
});
|
23
photos/backend/drizzle/0000_fast_rawhide_kid.sql
Normal file
23
photos/backend/drizzle/0000_fast_rawhide_kid.sql
Normal file
@ -0,0 +1,23 @@
|
||||
CREATE TABLE `photo_likes` (
|
||||
`photo_id` integer NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
PRIMARY KEY(`photo_id`, `user_id`),
|
||||
FOREIGN KEY (`photo_id`) REFERENCES `photos`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `photos` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`user_id` text(50) NOT NULL,
|
||||
`caption` text(250),
|
||||
`visible` integer DEFAULT false NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`username` text(50) NOT NULL,
|
||||
`password` text(50) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
169
photos/backend/drizzle/meta/0000_snapshot.json
Normal file
169
photos/backend/drizzle/meta/0000_snapshot.json
Normal file
@ -0,0 +1,169 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "f9a16b4f-d676-4bd0-beea-0d038d4a8953",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"photo_likes": {
|
||||
"name": "photo_likes",
|
||||
"columns": {
|
||||
"photo_id": {
|
||||
"name": "photo_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"photo_likes_photo_id_photos_id_fk": {
|
||||
"name": "photo_likes_photo_id_photos_id_fk",
|
||||
"tableFrom": "photo_likes",
|
||||
"tableTo": "photos",
|
||||
"columnsFrom": [
|
||||
"photo_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"photo_likes_user_id_users_id_fk": {
|
||||
"name": "photo_likes_user_id_users_id_fk",
|
||||
"tableFrom": "photo_likes",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"photo_likes_photo_id_user_id_pk": {
|
||||
"columns": [
|
||||
"photo_id",
|
||||
"user_id"
|
||||
],
|
||||
"name": "photo_likes_photo_id_user_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"photos": {
|
||||
"name": "photos",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"caption": {
|
||||
"name": "caption",
|
||||
"type": "text(250)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"visible": {
|
||||
"name": "visible",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"photos_user_id_users_id_fk": {
|
||||
"name": "photos_user_id_users_id_fk",
|
||||
"tableFrom": "photos",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
13
photos/backend/drizzle/meta/_journal.json
Normal file
13
photos/backend/drizzle/meta/_journal.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1731899224964,
|
||||
"tag": "0000_fast_rawhide_kid",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
37
photos/backend/package.json
Normal file
37
photos/backend/package.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "tsx watch src/app.ts",
|
||||
"build": "tsc"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee",
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"drizzle-kit": "^0.28.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@libsql/client": "^0.14.0",
|
||||
"argon2": "^0.41.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-orm": "^0.36.3",
|
||||
"express": "5",
|
||||
"express-session": "^1.18.1",
|
||||
"multer": "1.4.5-lts.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"postgres": "^3.4.5"
|
||||
}
|
||||
}
|
2185
photos/backend/pnpm-lock.yaml
Normal file
2185
photos/backend/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
27
photos/backend/src/app.ts
Normal file
27
photos/backend/src/app.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import "dotenv/config";
|
||||
import passport from "passport";
|
||||
import express from "express";
|
||||
|
||||
import * as auth from "./services/auth";
|
||||
import authRouter from "./routers/auth";
|
||||
import photosRouter from "./routers/photos";
|
||||
import { runMigrations } from "./db";
|
||||
|
||||
const app = express();
|
||||
|
||||
auth.init(app, passport);
|
||||
app.use(express.json());
|
||||
app.use(passport.session());
|
||||
|
||||
app.use("/auth", authRouter);
|
||||
app.use("/photos", photosRouter);
|
||||
|
||||
async function prepare() {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
await runMigrations();
|
||||
}
|
||||
}
|
||||
|
||||
prepare().then(() =>
|
||||
app.listen(8000, () => console.log("Server listening on port 8000."))
|
||||
);
|
22
photos/backend/src/db/index.ts
Normal file
22
photos/backend/src/db/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import "dotenv/config";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import * as schema from "./schema";
|
||||
import { ExtractTablesWithRelations } from "drizzle-orm";
|
||||
import { SQLiteTransaction } from "drizzle-orm/sqlite-core";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
|
||||
const db = drizzle(process.env.DB_FILE_NAME!, { schema, casing: "snake_case" });
|
||||
|
||||
type Transaction = SQLiteTransaction<
|
||||
"async",
|
||||
any,
|
||||
typeof schema,
|
||||
ExtractTablesWithRelations<typeof schema>
|
||||
>;
|
||||
|
||||
async function runMigrations() {
|
||||
await migrate(db, { migrationsFolder: "./drizzle" });
|
||||
}
|
||||
|
||||
export { schema, runMigrations, Transaction };
|
||||
export default db;
|
60
photos/backend/src/db/schema.ts
Normal file
60
photos/backend/src/db/schema.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
integer,
|
||||
primaryKey,
|
||||
sqliteTable,
|
||||
text,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import * as crypto from "node:crypto";
|
||||
|
||||
/** Users */
|
||||
export const users = sqliteTable("users", {
|
||||
id: text().notNull().primaryKey().$defaultFn(crypto.randomUUID),
|
||||
|
||||
username: text({ length: 50 }).notNull().unique(),
|
||||
password: text({ length: 50 }).notNull(),
|
||||
});
|
||||
|
||||
/** Photos */
|
||||
export const photos = sqliteTable("photos", {
|
||||
id: integer().notNull().primaryKey(),
|
||||
userId: text({ length: 50 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
|
||||
caption: text({ length: 250 }),
|
||||
visible: integer({ mode: "boolean" }).notNull().default(false),
|
||||
});
|
||||
|
||||
export const photosRelations = relations(photos, ({ one, many }) => ({
|
||||
user: one(users, {
|
||||
fields: [photos.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
likes: many(photoLikes),
|
||||
}));
|
||||
|
||||
/** Photo likes */
|
||||
export const photoLikes = sqliteTable(
|
||||
"photo_likes",
|
||||
{
|
||||
photoId: integer()
|
||||
.notNull()
|
||||
.references(() => photos.id),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
},
|
||||
(table) => ({ pk: primaryKey({ columns: [table.photoId, table.userId] }) })
|
||||
);
|
||||
|
||||
export const photosLikesRelations = relations(photoLikes, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [photoLikes.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
photo: one(photos, {
|
||||
fields: [photoLikes.photoId],
|
||||
references: [photos.id],
|
||||
}),
|
||||
}));
|
10
photos/backend/src/global.d.ts
vendored
Normal file
10
photos/backend/src/global.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
78
photos/backend/src/routers/auth.ts
Normal file
78
photos/backend/src/routers/auth.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { Router } from "express";
|
||||
import passport from "passport";
|
||||
|
||||
import * as users from "../services/users";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** Get user data */
|
||||
router.get("/me", (req, res) => {
|
||||
if (req.isAuthenticated()) {
|
||||
res.send(req.user);
|
||||
} else {
|
||||
res.status(401).send();
|
||||
}
|
||||
});
|
||||
|
||||
/** Sign in */
|
||||
router.post("/signin", passport.authenticate("local"), (req, res) => {
|
||||
res.status(200).send(req.user);
|
||||
});
|
||||
|
||||
/** Sign up */
|
||||
router.post("/signup", async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (req.isAuthenticated()) {
|
||||
res.status(400).send();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof username !== "string" ||
|
||||
username.length < 2 ||
|
||||
username.length > 50 ||
|
||||
typeof password !== "string" ||
|
||||
password.length < 2 ||
|
||||
password.length > 50
|
||||
) {
|
||||
res.status(400).send();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await users.create(username, password);
|
||||
|
||||
req.login(user, (err) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
res.status(500).send();
|
||||
} else {
|
||||
res.send(user);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Error &&
|
||||
e.message == "UNIQUE constraint failed: users.username"
|
||||
) {
|
||||
res.status(400).send("This username is already taken.");
|
||||
} else {
|
||||
console.error(e);
|
||||
res.status(500).send();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** Sign out */
|
||||
router.post("/signout", (req, res) => {
|
||||
req.logout((err) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
res.status(500).send();
|
||||
} else {
|
||||
res.status(200).send();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
125
photos/backend/src/routers/photos.ts
Normal file
125
photos/backend/src/routers/photos.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { Router } from "express";
|
||||
import multer from "multer";
|
||||
|
||||
import * as photos from "../services/photos";
|
||||
|
||||
const storage = multer.memoryStorage();
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { files: 1, fileSize: 5 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** Get photos feed */
|
||||
router.get("/", async (req, res) => {
|
||||
let { offset, userId } = req.query;
|
||||
let offsetNumber: number = 0;
|
||||
let userIdString: string = "";
|
||||
|
||||
try {
|
||||
if (typeof offset === "string") offsetNumber = parseInt(offset);
|
||||
} catch {}
|
||||
|
||||
if (typeof userId === "string") userIdString = userId;
|
||||
|
||||
if (offsetNumber < 0) {
|
||||
res.status(400).send();
|
||||
return;
|
||||
}
|
||||
|
||||
res.send(
|
||||
await photos.getFeedForUserId(req.user?.id, offsetNumber, userIdString)
|
||||
);
|
||||
});
|
||||
|
||||
/** Get photo file */
|
||||
router.get("/:id.jpeg", async (req, res) => {
|
||||
const id = +req.params.id;
|
||||
if (!isFinite(id)) {
|
||||
res.status(400).send("Invalid id.");
|
||||
return;
|
||||
}
|
||||
|
||||
const photo = await photos.findById(id);
|
||||
if (!photo) {
|
||||
res.status(404).send("Photo not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (photo.userId != req.user?.id && !photo.visible) {
|
||||
res.status(401).send();
|
||||
return;
|
||||
}
|
||||
|
||||
res.sendFile(`./data/${id}.jpeg`, { root: process.cwd() });
|
||||
});
|
||||
|
||||
/** Get photo data */
|
||||
router.get("/:id", async (req, res) => {
|
||||
const id = +req.params.id;
|
||||
if (!isFinite(id)) {
|
||||
res.status(400).send("Invalid id.");
|
||||
return;
|
||||
}
|
||||
|
||||
const photo = await photos.findByIdWithMetadata(req.user?.id, id);
|
||||
if (!photo) {
|
||||
res.status(404).send("Photo not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (photo.userId != req.user?.id && !photo.visible) {
|
||||
res.status(401).send();
|
||||
return;
|
||||
}
|
||||
|
||||
res.send(photo);
|
||||
});
|
||||
|
||||
/** Upload photo */
|
||||
router.post("/", upload.single("photo"), async (req, res) => {
|
||||
if (!req.isAuthenticated()) {
|
||||
res.status(401).send();
|
||||
return;
|
||||
}
|
||||
|
||||
const file = req.file;
|
||||
if (!file || file.mimetype !== "image/jpeg") {
|
||||
res.status(400).send("Unsupported file type.");
|
||||
return;
|
||||
}
|
||||
|
||||
const photo = await photos.create(
|
||||
req.user.id,
|
||||
file,
|
||||
req.body.caption,
|
||||
req.body.visible === "true"
|
||||
);
|
||||
|
||||
res.send(photo);
|
||||
});
|
||||
|
||||
/** Like or un-like a post */
|
||||
router.post("/:id/like", async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!isFinite(id)) {
|
||||
res.status(400).send("Invalid id.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.isAuthenticated()) {
|
||||
res.status(401).send();
|
||||
return;
|
||||
}
|
||||
|
||||
const photo = await photos.updateLike(
|
||||
req.user.id,
|
||||
id,
|
||||
req.body?.like === true
|
||||
);
|
||||
|
||||
res.send(photo);
|
||||
});
|
||||
|
||||
export default router;
|
77
photos/backend/src/services/auth.ts
Normal file
77
photos/backend/src/services/auth.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import express from "express";
|
||||
import { PassportStatic } from "passport";
|
||||
import LocalStrategy from "passport-local";
|
||||
import session from "express-session";
|
||||
import * as argon2 from "argon2";
|
||||
|
||||
import * as users from "./users";
|
||||
import db from "../db";
|
||||
|
||||
/** Deserializes a user from session data */
|
||||
async function deserializeUser(
|
||||
id: string,
|
||||
done: (err: any, user?: Express.User) => void
|
||||
) {
|
||||
try {
|
||||
const user = await users.findById(id);
|
||||
if (!user) return done(new Error("User not found."));
|
||||
done(undefined, user);
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Verifies user credentials */
|
||||
async function verify(
|
||||
username: string,
|
||||
password: string,
|
||||
cb: (
|
||||
error: any,
|
||||
user?: Express.User | false,
|
||||
options?: LocalStrategy.IVerifyOptions
|
||||
) => void
|
||||
) {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: (users, { eq }) => eq(users.username, username),
|
||||
});
|
||||
|
||||
if (!user) return cb(undefined, false);
|
||||
|
||||
try {
|
||||
if (await argon2.verify(user.password, password)) {
|
||||
const { password, ...data } = user;
|
||||
return cb(undefined, data);
|
||||
} else {
|
||||
return cb(undefined, false);
|
||||
}
|
||||
} catch {
|
||||
return cb(undefined, false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Initializes the authentication service */
|
||||
export function init(app: express.Application, passport: PassportStatic) {
|
||||
passport.serializeUser((user, done) => done(undefined, user.id));
|
||||
passport.deserializeUser(deserializeUser);
|
||||
|
||||
passport.use(
|
||||
new LocalStrategy.Strategy(
|
||||
{
|
||||
usernameField: "username",
|
||||
passwordField: "password",
|
||||
},
|
||||
verify
|
||||
)
|
||||
);
|
||||
|
||||
app.use(
|
||||
session({
|
||||
secret: process.env.SESSION_SECRET!,
|
||||
resave: true,
|
||||
saveUninitialized: true,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
122
photos/backend/src/services/photos.ts
Normal file
122
photos/backend/src/services/photos.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import db, { schema, Transaction } from "../db";
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
|
||||
/** Finds a photo by its id */
|
||||
export async function findById(id: number) {
|
||||
return await db.query.photos.findFirst({
|
||||
where: (photos, { eq }) => eq(photos.id, id),
|
||||
});
|
||||
}
|
||||
|
||||
/** Finds a photo by its id for a given user and returns additional metadata */
|
||||
export async function findByIdWithMetadata(
|
||||
userId: string | undefined,
|
||||
id: number,
|
||||
tx?: Transaction
|
||||
) {
|
||||
const photo = await (tx || db).query.photos.findFirst({
|
||||
where: (photos, { eq }) => eq(photos.id, id),
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
likes: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!photo) throw new Error("Photo not found.");
|
||||
const { likes, ...data } = photo;
|
||||
|
||||
return {
|
||||
...data,
|
||||
likes: likes.length,
|
||||
liked: !!likes.find((like) => like.userId === userId),
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates a new photo */
|
||||
export async function create(
|
||||
userId: string,
|
||||
file: Express.Multer.File,
|
||||
caption?: string,
|
||||
visible?: boolean
|
||||
) {
|
||||
return await db.transaction(async (tx) => {
|
||||
const [photo] = await tx
|
||||
.insert(schema.photos)
|
||||
.values({ userId, caption, visible })
|
||||
.returning();
|
||||
|
||||
await fs.writeFile(`./data/${photo.id}.jpeg`, file.buffer);
|
||||
|
||||
return photo;
|
||||
});
|
||||
}
|
||||
|
||||
/** Gets the photo for a given user */
|
||||
export async function getFeedForUserId(
|
||||
userId: string | undefined,
|
||||
offset: number,
|
||||
forUserId?: string
|
||||
) {
|
||||
const photos = await db.query.photos.findMany({
|
||||
where: (photos, { or, eq }) =>
|
||||
and(
|
||||
or(
|
||||
eq(photos.visible, true),
|
||||
userId ? eq(photos.userId, userId) : undefined
|
||||
),
|
||||
forUserId ? eq(photos.userId, forUserId) : undefined
|
||||
),
|
||||
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
likes: true,
|
||||
},
|
||||
offset,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
return photos.map(({ likes, ...photo }) => ({
|
||||
...photo,
|
||||
likes: likes.length,
|
||||
liked: !!likes.find((like) => like.userId === userId),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Updates the like status of a photo for a user */
|
||||
export async function updateLike(
|
||||
userId: string,
|
||||
photoId: number,
|
||||
like: boolean
|
||||
) {
|
||||
return await db.transaction(async (tx) => {
|
||||
if (like) {
|
||||
await tx
|
||||
.insert(schema.photoLikes)
|
||||
.values({ userId, photoId })
|
||||
.onConflictDoNothing();
|
||||
} else {
|
||||
await tx
|
||||
.delete(schema.photoLikes)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.photoLikes.photoId, photoId),
|
||||
eq(schema.photoLikes.userId, userId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return findByIdWithMetadata(userId, photoId, tx);
|
||||
});
|
||||
}
|
30
photos/backend/src/services/users.ts
Normal file
30
photos/backend/src/services/users.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import db, { schema } from "../db";
|
||||
import * as argon2 from "argon2";
|
||||
|
||||
/** Finds a user by their id */
|
||||
export async function findById(id: string) {
|
||||
return await db.query.users.findFirst({
|
||||
where: (users, { eq }) => eq(users.id, id),
|
||||
columns: { id: true, username: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Finds a user by their username */
|
||||
export async function findByUsername(username: string) {
|
||||
return await db.query.users.findFirst({
|
||||
where: (users, { eq }) => eq(users.username, username),
|
||||
columns: { id: true, username: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates a user */
|
||||
export async function create(username: string, password: string) {
|
||||
const hash = await argon2.hash(password);
|
||||
|
||||
const [user] = await db
|
||||
.insert(schema.users)
|
||||
.values({ username, password: hash })
|
||||
.returning({ id: schema.users.id, username: schema.users.username });
|
||||
|
||||
return user;
|
||||
}
|
110
photos/backend/tsconfig.json
Normal file
110
photos/backend/tsconfig.json
Normal file
@ -0,0 +1,110 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs" /* Specify what module code is generated. */,
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
33
photos/docker-compose.yaml
Normal file
33
photos/docker-compose.yaml
Normal file
@ -0,0 +1,33 @@
|
||||
services:
|
||||
nginx:
|
||||
build:
|
||||
context: ./nginx
|
||||
ports:
|
||||
- 3000:80
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
network_mode: service:nginx
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DB_FILE_NAME=file:local.db
|
||||
- SESSION_SECRET=secret
|
||||
network_mode: service:nginx
|
||||
|
||||
init:
|
||||
platform: linux/amd64
|
||||
build:
|
||||
context: ./init
|
||||
restart: "on-failure"
|
||||
depends_on:
|
||||
nginx:
|
||||
condition: service_started
|
||||
environment:
|
||||
- ADMIN_PASSWORD=supersecretpassword
|
||||
- FLAG_1=NOT-THE-REAL-FLAG-1
|
||||
network_mode: service:nginx
|
BIN
photos/fake-flag.jpeg
Normal file
BIN
photos/fake-flag.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 231 KiB |
BIN
photos/flag.jpeg
Normal file
BIN
photos/flag.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 622 KiB |
7
photos/frontend/.dockerignore
Normal file
7
photos/frontend/.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
3
photos/frontend/.eslintrc.json
Normal file
3
photos/frontend/.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
40
photos/frontend/.gitignore
vendored
Normal file
40
photos/frontend/.gitignore
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
64
photos/frontend/Dockerfile
Normal file
64
photos/frontend/Dockerfile
Normal file
@ -0,0 +1,64 @@
|
||||
# syntax=docker.io/docker/dockerfile:1
|
||||
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
# ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
# ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "server.js"]
|
22
photos/frontend/components.json
Normal file
22
photos/frontend/components.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks",
|
||||
"extension": "@/components/ui/extension"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
14
photos/frontend/next.config.ts
Normal file
14
photos/frontend/next.config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
rewrites: async () => [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: "http://localhost:8000/:path*",
|
||||
},
|
||||
],
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
44
photos/frontend/package.json
Normal file
44
photos/frontend/package.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@tanstack/react-query": "^5.60.5",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.460.0",
|
||||
"next": "15.0.3",
|
||||
"react": "19.0.0-rc-66855b96-20241106",
|
||||
"react-dom": "19.0.0-rc-66855b96-20241106",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"sonner": "^1.7.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "15.0.3",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee"
|
||||
}
|
4440
photos/frontend/pnpm-lock.yaml
Normal file
4440
photos/frontend/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
8
photos/frontend/postcss.config.mjs
Normal file
8
photos/frontend/postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
9
photos/frontend/src/app/auth/signin/page.tsx
Normal file
9
photos/frontend/src/app/auth/signin/page.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { SignInForm } from "@/components/signin-form";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center px-4">
|
||||
<SignInForm />
|
||||
</div>
|
||||
);
|
||||
}
|
9
photos/frontend/src/app/auth/signup/page.tsx
Normal file
9
photos/frontend/src/app/auth/signup/page.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { SignUpForm } from "@/components/signup-form";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center px-4">
|
||||
<SignUpForm />
|
||||
</div>
|
||||
);
|
||||
}
|
BIN
photos/frontend/src/app/fonts/GeistMonoVF.woff
Normal file
BIN
photos/frontend/src/app/fonts/GeistMonoVF.woff
Normal file
Binary file not shown.
BIN
photos/frontend/src/app/fonts/GeistVF.woff
Normal file
BIN
photos/frontend/src/app/fonts/GeistVF.woff
Normal file
Binary file not shown.
72
photos/frontend/src/app/globals.css
Normal file
72
photos/frontend/src/app/globals.css
Normal file
@ -0,0 +1,72 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
44
photos/frontend/src/app/layout.tsx
Normal file
44
photos/frontend/src/app/layout.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import Providers from "./providers";
|
||||
import Header from "@/components/header";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/GeistVF.woff",
|
||||
variable: "--font-geist-sans",
|
||||
weight: "100 900",
|
||||
});
|
||||
const geistMono = localFont({
|
||||
src: "./fonts/GeistMonoVF.woff",
|
||||
variable: "--font-geist-mono",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Photos",
|
||||
description: "Instagram 2.0",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased flex flex-col items-center`}
|
||||
>
|
||||
<div className="min-w-full md:min-w-[750px] px-6">
|
||||
<Providers>
|
||||
<Header />
|
||||
{children}
|
||||
</Providers>
|
||||
<Toaster />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
32
photos/frontend/src/app/mutations/like.tsx
Normal file
32
photos/frontend/src/app/mutations/like.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export default function useMutateLike(id: number, liked: boolean) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await fetch(`/api/photos/${id}/like`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ like: !liked }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
const newPhoto = await response.json();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryClient.setQueriesData({ queryKey: ["photos"] }, (data: any) => {
|
||||
if (Array.isArray(data.pages)) {
|
||||
return {
|
||||
pages: data.pages.map((photos: App.Photo[]) =>
|
||||
photos.map((photo) => (photo.id === id ? newPhoto : photo))
|
||||
),
|
||||
pageParams: data.pageParams,
|
||||
};
|
||||
} else {
|
||||
return newPhoto;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
90
photos/frontend/src/app/page.tsx
Normal file
90
photos/frontend/src/app/page.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import Photo from "@/components/photo";
|
||||
import Link from "next/link";
|
||||
import { LoaderCircleIcon } from "lucide-react";
|
||||
import useAuth from "@/hooks/use-auth";
|
||||
|
||||
async function getPhotos({ pageParam }: { pageParam: number }) {
|
||||
const response = await fetch(`/api/photos?offset=${pageParam}`);
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
|
||||
return (await response.json()) as App.Photo[];
|
||||
}
|
||||
|
||||
const LIMIT = 10;
|
||||
|
||||
export default function Home() {
|
||||
const auth = useAuth();
|
||||
|
||||
const photos = useInfiniteQuery({
|
||||
queryKey: ["photos"],
|
||||
queryFn: getPhotos,
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (page, pages) =>
|
||||
page.length > LIMIT ? pages.length * LIMIT : undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="font-[family-name:var(--font-geist-sans)] flex flex-col gap-8 pb-8">
|
||||
<h1 className="text-2xl font-semibold">Feed</h1>
|
||||
|
||||
{photos.isError ? (
|
||||
"error"
|
||||
) : photos.isSuccess ? (
|
||||
photos.data.pages.map((group, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{group.map((photo) => (
|
||||
<Photo key={photo.id} {...photo} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
Loading... <LoaderCircleIcon className="size-12 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div>
|
||||
{photos.hasNextPage ? (
|
||||
<Button
|
||||
onClick={() => photos.fetchNextPage()}
|
||||
disabled={!photos.hasNextPage || photos.isFetchingNextPage}
|
||||
>
|
||||
{photos.isFetchingNextPage
|
||||
? "Loading more..."
|
||||
: photos.hasNextPage
|
||||
? "Load More"
|
||||
: "Nothing more to load"}
|
||||
</Button>
|
||||
) : !photos.isFetching ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<span>There are no more photos :(</span>
|
||||
{auth.user ? (
|
||||
<>
|
||||
<span>Why don't you upload one?</span>
|
||||
<Button asChild className="mt-3">
|
||||
<Link href="/upload">Upload photo</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Create an account to upload photos.</span>
|
||||
<Button asChild className="mt-3 w-full">
|
||||
<Link href="/auth/signup">Sign up</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
72
photos/frontend/src/app/photos/[id]/page.tsx
Normal file
72
photos/frontend/src/app/photos/[id]/page.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import useMutateLike from "@/app/mutations/like";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import useAuth from "@/hooks/use-auth";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Image from "next/image";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
export default function Page() {
|
||||
const auth = useAuth();
|
||||
const params = useParams<{ id: string }>();
|
||||
|
||||
const photo = useQuery({
|
||||
queryKey: ["photos", params.id],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`/api/photos/${params.id}`);
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
|
||||
return (await response.json()) as App.Photo;
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutateLike(
|
||||
photo.data?.id || 0,
|
||||
photo.data?.liked || false
|
||||
);
|
||||
|
||||
if (photo.isLoading) return <div>Loading...</div>;
|
||||
if (photo.isError) {
|
||||
if (photo.error instanceof Error) return <div>{photo.error.message}</div>;
|
||||
return <div>Unknown error</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-fit">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative w-[75vw] aspect-square md:size-96 border rounded-sm shadow-sm">
|
||||
<Image
|
||||
src={`/api/photos/${params.id}.jpeg`}
|
||||
alt=""
|
||||
fill
|
||||
className="object-scale-down"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-semibold mt-6 w-fit">
|
||||
Photo by {photo.data?.user?.username}
|
||||
</h1>
|
||||
|
||||
<Label>Caption</Label>
|
||||
<p className="w-fit">{photo.data?.caption}</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Likes</Label>
|
||||
<p>{photo.data?.likes}</p>
|
||||
</div>
|
||||
|
||||
{!auth.isLoading && auth.user && (
|
||||
<Button onClick={() => mutation.mutate()}>
|
||||
{photo.data?.liked ? "Un-like" : "Like"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
96
photos/frontend/src/app/profile/page.tsx
Normal file
96
photos/frontend/src/app/profile/page.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import useAuth from "../../hooks/use-auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Photo from "@/components/photo";
|
||||
|
||||
async function getPhotos({
|
||||
queryKey,
|
||||
pageParam,
|
||||
}: {
|
||||
queryKey: (string | undefined)[];
|
||||
pageParam: number;
|
||||
}) {
|
||||
const response = await fetch(
|
||||
`/api/photos?offset=${pageParam}&userId=${queryKey[1]}`
|
||||
);
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
|
||||
return (await response.json()) as App.Photo[];
|
||||
}
|
||||
|
||||
const LIMIT = 10;
|
||||
|
||||
export default function Page() {
|
||||
const auth = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!auth.isLoading && !auth.isError && auth.user === null)
|
||||
router.push("/auth/signin");
|
||||
}, [auth.isLoading, auth.isError, auth.user, router]);
|
||||
|
||||
const photos = useInfiniteQuery({
|
||||
queryKey: ["photos", auth.user?.id],
|
||||
queryFn: getPhotos,
|
||||
enabled: !!auth.user,
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (page, pages) =>
|
||||
page.length > LIMIT ? pages.length * LIMIT : undefined,
|
||||
});
|
||||
|
||||
if (auth.isLoading) return <div>loading...</div>;
|
||||
if (auth.isError) return <div>error</div>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<Avatar className="size-44">
|
||||
<AvatarFallback className="text-6xl">
|
||||
{auth.user?.username.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<h1 className="text-3xl font-semibold mt-5">Your profile</h1>
|
||||
|
||||
<h1 className="text-2xl font-semibold mt-4">Photos</h1>
|
||||
|
||||
<div className="flex flex-col items-center gap-4 mt-4 pb-8 w-full">
|
||||
{photos.isError
|
||||
? "error"
|
||||
: photos.isSuccess
|
||||
? photos.data.pages.map((group, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{group.map((photo) => (
|
||||
<Photo key={photo.id} {...photo} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
))
|
||||
: "loading...."}
|
||||
|
||||
<div className="flex flex-col items-center mt-4">
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => photos.fetchNextPage()}
|
||||
disabled={!photos.hasNextPage || photos.isFetchingNextPage}
|
||||
>
|
||||
{photos.isFetchingNextPage
|
||||
? "Loading more..."
|
||||
: photos.hasNextPage
|
||||
? "Load More"
|
||||
: "Nothing more to load"}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
{photos.isFetching && !photos.isFetchingNextPage
|
||||
? "Fetching..."
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
50
photos/frontend/src/app/providers.tsx
Normal file
50
photos/frontend/src/app/providers.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
// In Next.js, this file would be called: app/providers.tsx
|
||||
"use client";
|
||||
|
||||
// Since QueryClientProvider relies on useContext under the hood, we have to put 'use client' on top
|
||||
import {
|
||||
isServer,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// With SSR, we usually want to set some default staleTime
|
||||
// above 0 to avoid refetching immediately on the client
|
||||
staleTime: 60 * 1000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let browserQueryClient: QueryClient | undefined = undefined;
|
||||
|
||||
function getQueryClient() {
|
||||
if (isServer) {
|
||||
// Server: always make a new query client
|
||||
return makeQueryClient();
|
||||
} else {
|
||||
// Browser: make a new query client if we don't already have one
|
||||
// This is very important, so we don't re-make a new client if React
|
||||
// suspends during the initial render. This may not be needed if we
|
||||
// have a suspense boundary BELOW the creation of the query client
|
||||
if (!browserQueryClient) browserQueryClient = makeQueryClient();
|
||||
return browserQueryClient;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
// NOTE: Avoid useState when initializing the query client if you don't
|
||||
// have a suspense boundary between this and the code that may
|
||||
// suspend because React will throw away the client on the initial
|
||||
// render if it suspends and there is no boundary
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
198
photos/frontend/src/app/upload/page.tsx
Normal file
198
photos/frontend/src/app/upload/page.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FileUploader,
|
||||
FileUploaderContent,
|
||||
FileUploaderItem,
|
||||
FileInput,
|
||||
} from "@/components/ui/extension/file-upload";
|
||||
import { CloudUpload, Paperclip } from "lucide-react";
|
||||
import React from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useAuth from "@/hooks/use-auth";
|
||||
|
||||
const formSchema = z.object({
|
||||
files: z
|
||||
.array(
|
||||
z.any().refine((file) => file.size < 5 * 1024 * 1024, {
|
||||
message: "Photo size must be less than 5MB.",
|
||||
})
|
||||
)
|
||||
.max(5, { message: "You can only upload one photo at a time." })
|
||||
.min(1, { message: "A photo is required." }),
|
||||
caption: z.string().max(250).optional(),
|
||||
visible: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export default function Page() {
|
||||
const auth = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!auth.isLoading && !auth.isError && !auth.user)
|
||||
router.push("/auth/signin");
|
||||
}, [auth.isLoading, auth.isError, auth.user, router]);
|
||||
|
||||
const dropZoneConfig = {
|
||||
maxFiles: 1,
|
||||
maxSize: 1024 * 1024 * 4,
|
||||
multiple: false,
|
||||
accept: {
|
||||
"image/jpeg": [".jpg", ".jpeg"],
|
||||
},
|
||||
};
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
caption: "",
|
||||
visible: false,
|
||||
files: [],
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: z.infer<typeof formSchema>) => {
|
||||
const body = new FormData();
|
||||
body.append("photo", data.files[0]);
|
||||
if (data.caption) body.append("caption", data.caption);
|
||||
if (data.visible) body.append("visible", "true");
|
||||
|
||||
const response = await fetch("/api/photos", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
|
||||
return (await response.json()) as App.Photo;
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
try {
|
||||
const photo = await mutation.mutateAsync(values);
|
||||
queryClient.resetQueries({ queryKey: ["photos"] });
|
||||
queryClient.setQueryData(["photos", photo.id], photo);
|
||||
toast.success("Photo uploaded!");
|
||||
router.push(`/photos/${photo.id}`);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
form.setError("root", { message: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold mb-4">Upload a photo</h1>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="files"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid gap-2">
|
||||
<FileUploader
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
dropzoneOptions={dropZoneConfig}
|
||||
className="relative bg-background rounded-lg p-2"
|
||||
>
|
||||
<FileInput className="outline-dashed outline-1 outline-white">
|
||||
<div className="flex items-center justify-center flex-col pt-3 pb-4 w-full ">
|
||||
<CloudUpload />
|
||||
<p className="mb-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-semibold">Click to upload</span>
|
||||
or drag and drop
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Only JPG please
|
||||
</p>
|
||||
</div>
|
||||
</FileInput>
|
||||
<FileUploaderContent>
|
||||
{field.value &&
|
||||
field.value.length > 0 &&
|
||||
field.value.map((file, i) => (
|
||||
<FileUploaderItem key={i} index={i}>
|
||||
<Paperclip className="h-4 w-4 stroke-current" />
|
||||
<span>{file.name}</span>
|
||||
</FileUploaderItem>
|
||||
))}
|
||||
</FileUploaderContent>
|
||||
</FileUploader>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="caption"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid gap-2">
|
||||
<FormLabel>Caption</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Describe your photo"
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="visible"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4 gap-5">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Make photo public</FormLabel>
|
||||
<FormDescription>
|
||||
Whether or not this photo should be visible for all users.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.formState.errors.root && (
|
||||
<p className={"text-[0.8rem] font-medium text-destructive"}>
|
||||
{form.formState.errors.root.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit">Upload</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
65
photos/frontend/src/components/header.tsx
Normal file
65
photos/frontend/src/components/header.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button } from "./ui/button";
|
||||
import Link from "next/link";
|
||||
import useAuth from "@/hooks/use-auth";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback } from "./ui/avatar";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
|
||||
export default function Header() {
|
||||
const pathname = usePathname();
|
||||
const auth = useAuth();
|
||||
|
||||
if (pathname.startsWith("/auth")) return <></>;
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between w-full py-4">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<ImageIcon className="size-5 mb-1" /> Photos
|
||||
</Link>
|
||||
|
||||
{auth.user ? (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost">
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback>
|
||||
{auth.user.username.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div>{auth.user.username}</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/profile">Profile</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/upload">Upload a photo</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={auth.signOut}>
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : !auth.isLoading ? (
|
||||
<Button asChild>
|
||||
<Link href="/auth/signin">Sign in</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="h-9" />
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
47
photos/frontend/src/components/photo.tsx
Normal file
47
photos/frontend/src/components/photo.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import useMutateLike from "@/app/mutations/like";
|
||||
import Image from "next/image";
|
||||
import { Card } from "./ui/card";
|
||||
import Link from "next/link";
|
||||
import { Label } from "./ui/label";
|
||||
import { Button } from "./ui/button";
|
||||
import useAuth from "@/hooks/use-auth";
|
||||
|
||||
export default function Photo({ id, user, caption, likes, liked }: App.Photo) {
|
||||
const auth = useAuth();
|
||||
const mutation = useMutateLike(id, liked);
|
||||
|
||||
return (
|
||||
<Card className="p-4 flex gap-6 w-full">
|
||||
<div className="relative w-[25vw] md:size-64">
|
||||
<Image
|
||||
src={`/api/photos/${id}.jpeg`}
|
||||
alt=""
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1">
|
||||
<Link href={`/photos/${id}`}>
|
||||
<h2>Photo by {user.username}</h2>
|
||||
</Link>
|
||||
|
||||
<Label>Caption</Label>
|
||||
<p>{caption ?? "This photo does not have a caption."}</p>
|
||||
|
||||
<div className="mt-auto flex justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Label>Likes</Label>
|
||||
{likes}
|
||||
</div>
|
||||
|
||||
{!auth.isLoading && auth.user && (
|
||||
<Button onClick={() => mutation.mutate()}>
|
||||
{liked ? "Un-like" : "Like"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
155
photos/frontend/src/components/signin-form.tsx
Normal file
155
photos/frontend/src/components/signin-form.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import useAuth from "@/hooks/use-auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
|
||||
const formSchema = z.object({
|
||||
username: z.string().min(2).max(50),
|
||||
password: z.string().min(2).max(50),
|
||||
});
|
||||
|
||||
export function SignInForm() {
|
||||
const router = useRouter();
|
||||
const auth = useAuth();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (auth.user) router.push("/");
|
||||
}, [router, auth.user]);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: z.infer<typeof formSchema>) => {
|
||||
const response = await fetch("/api/auth/signin", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
|
||||
return (await response.json()) as App.User;
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
try {
|
||||
const user = await mutation.mutateAsync(values);
|
||||
auth.setUser(user);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message == "Unauthorized") {
|
||||
form.setError("root", {
|
||||
message: "Your username or password is incorrect.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-1 pl-3 font-semibold">
|
||||
<ImageIcon className="size-5 mb-1" /> Photos
|
||||
</div>
|
||||
<Card className="mx-auto max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Sign in</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email below to login to your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid gap-2">
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="username"
|
||||
autoComplete="username"
|
||||
required
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid gap-2">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="password"
|
||||
required
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.formState.errors.root && (
|
||||
<p className={"text-[0.8rem] font-medium text-destructive"}>
|
||||
{form.formState.errors.root.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit">Sign in</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/auth/signup" className="underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
154
photos/frontend/src/components/signup-form.tsx
Normal file
154
photos/frontend/src/components/signup-form.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import useAuth from "@/hooks/use-auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
|
||||
const formSchema = z.object({
|
||||
username: z.string().min(2).max(50),
|
||||
password: z.string().min(2).max(50),
|
||||
});
|
||||
|
||||
export function SignUpForm() {
|
||||
const router = useRouter();
|
||||
const auth = useAuth();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (auth.user) router.push("/");
|
||||
}, [router, auth.user]);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: z.infer<typeof formSchema>) => {
|
||||
const response = await fetch("/api/auth/signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error((await response.text()) || response.statusText);
|
||||
|
||||
return (await response.json()) as App.User;
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
try {
|
||||
const user = await mutation.mutateAsync(values);
|
||||
auth.setUser(user);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
form.setError("root", { message: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-1 pl-3 font-semibold">
|
||||
<ImageIcon className="size-5 mb-1" /> Photos
|
||||
</div>
|
||||
<Card className="mx-auto max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Sign up</CardTitle>
|
||||
<CardDescription>
|
||||
Create an account to upload your own photos!
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid gap-2">
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="username"
|
||||
autoComplete="username"
|
||||
required
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid gap-2">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="password"
|
||||
required
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.formState.errors.root && (
|
||||
<p className={"text-[0.8rem] font-medium text-destructive"}>
|
||||
{form.formState.errors.root.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit">Sign up</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<Link href="/auth/signin" className="underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
50
photos/frontend/src/components/ui/avatar.tsx
Normal file
50
photos/frontend/src/components/ui/avatar.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
57
photos/frontend/src/components/ui/button.tsx
Normal file
57
photos/frontend/src/components/ui/button.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
76
photos/frontend/src/components/ui/card.tsx
Normal file
76
photos/frontend/src/components/ui/card.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
201
photos/frontend/src/components/ui/dropdown-menu.tsx
Normal file
201
photos/frontend/src/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
364
photos/frontend/src/components/ui/extension/file-upload.tsx
Normal file
364
photos/frontend/src/components/ui/extension/file-upload.tsx
Normal file
@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
useDropzone,
|
||||
DropzoneState,
|
||||
FileRejection,
|
||||
DropzoneOptions,
|
||||
} from "react-dropzone";
|
||||
import { toast } from "sonner";
|
||||
import { Trash2 as RemoveIcon } from "lucide-react";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
type DirectionOptions = "rtl" | "ltr" | undefined;
|
||||
|
||||
type FileUploaderContextType = {
|
||||
dropzoneState: DropzoneState;
|
||||
isLOF: boolean;
|
||||
isFileTooBig: boolean;
|
||||
removeFileFromSet: (index: number) => void;
|
||||
activeIndex: number;
|
||||
setActiveIndex: Dispatch<SetStateAction<number>>;
|
||||
orientation: "horizontal" | "vertical";
|
||||
direction: DirectionOptions;
|
||||
};
|
||||
|
||||
const FileUploaderContext = createContext<FileUploaderContextType | null>(null);
|
||||
|
||||
export const useFileUpload = () => {
|
||||
const context = useContext(FileUploaderContext);
|
||||
if (!context) {
|
||||
throw new Error("useFileUpload must be used within a FileUploaderProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
type FileUploaderProps = {
|
||||
value: File[] | null;
|
||||
reSelect?: boolean;
|
||||
onValueChange: (value: File[] | null) => void;
|
||||
dropzoneOptions: DropzoneOptions;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
};
|
||||
|
||||
/**
|
||||
* File upload Docs: {@link: https://localhost:3000/docs/file-upload}
|
||||
*/
|
||||
|
||||
export const FileUploader = forwardRef<
|
||||
HTMLDivElement,
|
||||
FileUploaderProps & React.HTMLAttributes<HTMLDivElement>
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
dropzoneOptions,
|
||||
value,
|
||||
onValueChange,
|
||||
reSelect,
|
||||
orientation = "vertical",
|
||||
children,
|
||||
dir,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [isFileTooBig, setIsFileTooBig] = useState(false);
|
||||
const [isLOF, setIsLOF] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const {
|
||||
accept = {
|
||||
"image/*": [".jpg", ".jpeg", ".png", ".gif"],
|
||||
},
|
||||
maxFiles = 1,
|
||||
maxSize = 4 * 1024 * 1024,
|
||||
multiple = true,
|
||||
} = dropzoneOptions;
|
||||
|
||||
const reSelectAll = maxFiles === 1 ? true : reSelect;
|
||||
const direction: DirectionOptions = dir === "rtl" ? "rtl" : "ltr";
|
||||
|
||||
const removeFileFromSet = useCallback(
|
||||
(i: number) => {
|
||||
if (!value) return;
|
||||
const newFiles = value.filter((_, index) => index !== i);
|
||||
onValueChange(newFiles);
|
||||
},
|
||||
[value, onValueChange],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!value) return;
|
||||
|
||||
const moveNext = () => {
|
||||
const nextIndex = activeIndex + 1;
|
||||
setActiveIndex(nextIndex > value.length - 1 ? 0 : nextIndex);
|
||||
};
|
||||
|
||||
const movePrev = () => {
|
||||
const nextIndex = activeIndex - 1;
|
||||
setActiveIndex(nextIndex < 0 ? value.length - 1 : nextIndex);
|
||||
};
|
||||
|
||||
const prevKey =
|
||||
orientation === "horizontal"
|
||||
? direction === "ltr"
|
||||
? "ArrowLeft"
|
||||
: "ArrowRight"
|
||||
: "ArrowUp";
|
||||
|
||||
const nextKey =
|
||||
orientation === "horizontal"
|
||||
? direction === "ltr"
|
||||
? "ArrowRight"
|
||||
: "ArrowLeft"
|
||||
: "ArrowDown";
|
||||
|
||||
if (e.key === nextKey) {
|
||||
moveNext();
|
||||
} else if (e.key === prevKey) {
|
||||
movePrev();
|
||||
} else if (e.key === "Enter" || e.key === "Space") {
|
||||
if (activeIndex === -1) {
|
||||
dropzoneState.inputRef.current?.click();
|
||||
}
|
||||
} else if (e.key === "Delete" || e.key === "Backspace") {
|
||||
if (activeIndex !== -1) {
|
||||
removeFileFromSet(activeIndex);
|
||||
if (value.length - 1 === 0) {
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
movePrev();
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[value, activeIndex, removeFileFromSet],
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
||||
const files = acceptedFiles;
|
||||
|
||||
if (!files) {
|
||||
toast.error("file error , probably too big");
|
||||
return;
|
||||
}
|
||||
|
||||
const newValues: File[] = value ? [...value] : [];
|
||||
|
||||
if (reSelectAll) {
|
||||
newValues.splice(0, newValues.length);
|
||||
}
|
||||
|
||||
files.forEach((file) => {
|
||||
if (newValues.length < maxFiles) {
|
||||
newValues.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
onValueChange(newValues);
|
||||
|
||||
if (rejectedFiles.length > 0) {
|
||||
for (let i = 0; i < rejectedFiles.length; i++) {
|
||||
if (rejectedFiles[i].errors[0]?.code === "file-too-large") {
|
||||
toast.error(
|
||||
`File is too large. Max size is ${maxSize / 1024 / 1024}MB`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (rejectedFiles[i].errors[0]?.message) {
|
||||
toast.error(rejectedFiles[i].errors[0].message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[reSelectAll, value],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) return;
|
||||
if (value.length === maxFiles) {
|
||||
setIsLOF(true);
|
||||
return;
|
||||
}
|
||||
setIsLOF(false);
|
||||
}, [value, maxFiles]);
|
||||
|
||||
const opts = dropzoneOptions
|
||||
? dropzoneOptions
|
||||
: { accept, maxFiles, maxSize, multiple };
|
||||
|
||||
const dropzoneState = useDropzone({
|
||||
...opts,
|
||||
onDrop,
|
||||
onDropRejected: () => setIsFileTooBig(true),
|
||||
onDropAccepted: () => setIsFileTooBig(false),
|
||||
});
|
||||
|
||||
return (
|
||||
<FileUploaderContext.Provider
|
||||
value={{
|
||||
dropzoneState,
|
||||
isLOF,
|
||||
isFileTooBig,
|
||||
removeFileFromSet,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
orientation,
|
||||
direction,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
tabIndex={0}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn(
|
||||
"grid w-full focus:outline-none overflow-hidden ",
|
||||
className,
|
||||
{
|
||||
"gap-2": value && value.length > 0,
|
||||
},
|
||||
)}
|
||||
dir={dir}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</FileUploaderContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FileUploader.displayName = "FileUploader";
|
||||
|
||||
export const FileUploaderContent = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ children, className, ...props }, ref) => {
|
||||
const { orientation } = useFileUpload();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-full px-1")}
|
||||
ref={containerRef}
|
||||
aria-description="content file holder"
|
||||
>
|
||||
<div
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex rounded-xl gap-1",
|
||||
orientation === "horizontal" ? "flex-raw flex-wrap" : "flex-col",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
FileUploaderContent.displayName = "FileUploaderContent";
|
||||
|
||||
export const FileUploaderItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ index: number } & React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, index, children, ...props }, ref) => {
|
||||
const { removeFileFromSet, activeIndex, direction } = useFileUpload();
|
||||
const isSelected = index === activeIndex;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-6 p-1 justify-between cursor-pointer relative",
|
||||
className,
|
||||
isSelected ? "bg-muted" : "",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="font-medium leading-none tracking-tight flex items-center gap-1.5 h-full w-full">
|
||||
{children}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute",
|
||||
direction === "rtl" ? "top-1 left-1" : "top-1 right-1",
|
||||
)}
|
||||
onClick={() => removeFileFromSet(index)}
|
||||
>
|
||||
<span className="sr-only">remove item {index}</span>
|
||||
<RemoveIcon className="w-4 h-4 hover:stroke-destructive duration-200 ease-in-out" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
FileUploaderItem.displayName = "FileUploaderItem";
|
||||
|
||||
export const FileInput = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { dropzoneState, isFileTooBig, isLOF } = useFileUpload();
|
||||
const rootProps = isLOF ? {} : dropzoneState.getRootProps();
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={`relative w-full ${
|
||||
isLOF ? "opacity-50 cursor-not-allowed " : "cursor-pointer "
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
`w-full rounded-lg duration-300 ease-in-out
|
||||
${
|
||||
dropzoneState.isDragAccept
|
||||
? "border-green-500"
|
||||
: dropzoneState.isDragReject || isFileTooBig
|
||||
? "border-red-500"
|
||||
: "border-gray-300"
|
||||
}`,
|
||||
className,
|
||||
)}
|
||||
{...rootProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<Input
|
||||
ref={dropzoneState.inputRef}
|
||||
disabled={isLOF}
|
||||
{...dropzoneState.getInputProps()}
|
||||
className={`${isLOF ? "cursor-not-allowed" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
FileInput.displayName = "FileInput";
|
178
photos/frontend/src/components/ui/form.tsx
Normal file
178
photos/frontend/src/components/ui/form.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
22
photos/frontend/src/components/ui/input.tsx
Normal file
22
photos/frontend/src/components/ui/input.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
26
photos/frontend/src/components/ui/label.tsx
Normal file
26
photos/frontend/src/components/ui/label.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
29
photos/frontend/src/components/ui/switch.tsx
Normal file
29
photos/frontend/src/components/ui/switch.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
22
photos/frontend/src/components/ui/textarea.tsx
Normal file
22
photos/frontend/src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
129
photos/frontend/src/components/ui/toast.tsx
Normal file
129
photos/frontend/src/components/ui/toast.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
35
photos/frontend/src/components/ui/toaster.tsx
Normal file
35
photos/frontend/src/components/ui/toaster.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
21
photos/frontend/src/global.d.ts
vendored
Normal file
21
photos/frontend/src/global.d.ts
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
declare global {
|
||||
namespace App {
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface Photo {
|
||||
id: number;
|
||||
|
||||
userId: number;
|
||||
user: App.User;
|
||||
caption?: string;
|
||||
likes: number;
|
||||
visible: boolean;
|
||||
liked: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
41
photos/frontend/src/hooks/use-auth.tsx
Normal file
41
photos/frontend/src/hooks/use-auth.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
async function getMe() {
|
||||
const response = await fetch("/api/auth/me");
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) return null;
|
||||
throw new Error(response.statusText);
|
||||
} else {
|
||||
return (await response.json()) as App.User;
|
||||
}
|
||||
}
|
||||
|
||||
export default function useAuth() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { isLoading, isError, data } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: getMe,
|
||||
});
|
||||
|
||||
function setUser(user: App.User) {
|
||||
queryClient.setQueryData(["me"], user);
|
||||
queryClient.resetQueries({ queryKey: ["photos"] });
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await fetch("/api/auth/signout", { method: "POST" });
|
||||
|
||||
queryClient.invalidateQueries({ type: "all" });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isError,
|
||||
user: data,
|
||||
setUser,
|
||||
signOut,
|
||||
};
|
||||
}
|
192
photos/frontend/src/hooks/use-toast.ts
Normal file
192
photos/frontend/src/hooks/use-toast.ts
Normal file
@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react";
|
||||
|
||||
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
};
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action;
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
};
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { useToast, toast };
|
6
photos/frontend/src/lib/utils.ts
Normal file
6
photos/frontend/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
62
photos/frontend/tailwind.config.ts
Normal file
62
photos/frontend/tailwind.config.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
} satisfies Config;
|
27
photos/frontend/tsconfig.json
Normal file
27
photos/frontend/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
5
photos/init/Dockerfile
Normal file
5
photos/init/Dockerfile
Normal file
@ -0,0 +1,5 @@
|
||||
FROM ghcr.io/puppeteer/puppeteer
|
||||
|
||||
COPY index.mjs flag.jpeg .
|
||||
|
||||
CMD ["node", "index.mjs"]
|
BIN
photos/init/flag.jpeg
Normal file
BIN
photos/init/flag.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 622 KiB |
35
photos/init/index.mjs
Normal file
35
photos/init/index.mjs
Normal file
@ -0,0 +1,35 @@
|
||||
import puppeteer from "puppeteer";
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? "http://localhost";
|
||||
|
||||
const browser = await puppeteer.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(`${BASE_URL}/auth/signup`);
|
||||
|
||||
await page.locator('[name="username"]').fill("admin");
|
||||
await page.locator('[name="password"]').fill(process.env.ADMIN_PASSWORD);
|
||||
|
||||
await page.locator('[type="submit"]').click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
|
||||
if (page.content.toString().includes("This username is already taken.")) {
|
||||
console.log("admin user exists");
|
||||
} else {
|
||||
console.log("admin user created");
|
||||
await page.goto(`${BASE_URL}/upload`);
|
||||
|
||||
const elementHandle = await page.$("input[type=file]");
|
||||
await elementHandle.uploadFile("./flag.jpeg");
|
||||
await page.locator('[name="caption"]').fill(process.env.FLAG_1);
|
||||
await page.locator('[type="submit"]').click();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
} catch (e) {
|
||||
console.error("Script failed to run due to an error", e);
|
||||
process.exit(1);
|
||||
}
|
3
photos/nginx/Dockerfile
Normal file
3
photos/nginx/Dockerfile
Normal file
@ -0,0 +1,3 @@
|
||||
FROM nginx
|
||||
|
||||
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
|
28
photos/nginx/nginx.conf
Normal file
28
photos/nginx/nginx.conf
Normal file
@ -0,0 +1,28 @@
|
||||
|
||||
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=static_cache:10m max_size=1g inactive=60m use_temp_path=off;
|
||||
|
||||
# From nextjs example nginx config
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Proxy /api to backend
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:8000/;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location /_next/ {
|
||||
proxy_ignore_headers "Cache-Control" "Vary";
|
||||
|
||||
proxy_cache static_cache;
|
||||
proxy_cache_key $uri;
|
||||
proxy_cache_valid any 48h;
|
||||
|
||||
proxy_pass http://localhost:3000;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000/;
|
||||
}
|
||||
}
|
BIN
photos/photos-source-code.zip
Normal file
BIN
photos/photos-source-code.zip
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user