Massive refactor
@@ -1,54 +0,0 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
const config = {
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
plugins: ["@typescript-eslint", "drizzle"],
|
||||
extends: [
|
||||
"next/core-web-vitals",
|
||||
"plugin:@typescript-eslint/recommended-type-checked",
|
||||
"plugin:@typescript-eslint/stylistic-type-checked",
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/no-empty-object-type": "off",
|
||||
"@typescript-eslint/prefer-nullish-coalescing": "off",
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/consistent-type-definitions": "off",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
{
|
||||
prefer: "type-imports",
|
||||
fixStyle: "inline-type-imports",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/require-await": "off",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{
|
||||
checksVoidReturn: {
|
||||
attributes: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
"drizzle/enforce-delete-with-where": [
|
||||
"error",
|
||||
{
|
||||
drizzleObjectName: ["db", "ctx.db"],
|
||||
},
|
||||
],
|
||||
"drizzle/enforce-update-with-where": [
|
||||
"error",
|
||||
{
|
||||
drizzleObjectName: ["db", "ctx.db"],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
module.exports = config;
|
||||
11
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Copilot Instructions
|
||||
1. Always use bun install to install new packages.
|
||||
2. Use bun run to execute scripts defined in package.json.
|
||||
3. For managing dependencies, prefer bun add <package> over npm install <package>.
|
||||
4. Components should be CamelCase and files should be kebab-case.
|
||||
5. Answer all questions in the style of a friendly colleague, using informal language.
|
||||
6. Don't go overboard with explanations; keep it concise and to the point.
|
||||
7. Use comments in code to clarify complex logic, but keep them brief.
|
||||
8. When writing tests, use the same naming conventions as the components.
|
||||
9. Always check for existing issues or discussions before creating new ones.
|
||||
10. When generating new components, make sure to follow the project's existing tailwind/shadcn styles and include light & dark mode support.
|
||||
27
.gitignore
vendored
@@ -3,20 +3,19 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# database
|
||||
/prisma/db.sqlite
|
||||
/prisma/db.sqlite-journal
|
||||
db.sqlite
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
next-env.d.ts
|
||||
|
||||
# production
|
||||
/build
|
||||
@@ -31,19 +30,13 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
||||
.env
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
.notenv*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# idea files
|
||||
.idea.env
|
||||
.idea/
|
||||
.vscode
|
||||
public/uploads/*
|
||||
next-env.d.ts
|
||||
INSTRUCTIONS.md
|
||||
10
.ncurc.json
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"reject": [
|
||||
"eslint",
|
||||
"typescript",
|
||||
"@types/eslint",
|
||||
"@typescript-eslint/eslint-plugin",
|
||||
"@typescript-eslint/parser",
|
||||
"eslint-config-next"
|
||||
]
|
||||
}
|
||||
182
README.md
@@ -1,3 +1,181 @@
|
||||
# Warning, may contain traces of webp
|
||||
# OpenGifame - Image Sharing Platform
|
||||
|
||||
[](https://app.netlify.com/sites/opengifame/deploys)
|
||||
An open-source image sharing platform similar to Imgur, built with Next.js, Drizzle ORM, PostgreSQL, NextAuth, and shadcn/ui.
|
||||
|
||||
## Features
|
||||
|
||||
- 🖼️ **Image Upload & Sharing** - Upload and share images with the community
|
||||
- 👍 **Voting System** - Upvote and downvote images
|
||||
- 💬 **Comments** - Comment on images with threaded replies
|
||||
- 🏷️ **Tagging System** - Tag images and create new tags
|
||||
- 📈 **Trending Gallery** - View trending images based on community votes
|
||||
- 🔐 **Authentication** - Sign in with GitHub or Google
|
||||
- 🌙 **Dark/Light Mode** - Responsive design with theme support
|
||||
- 📱 **Mobile Responsive** - Works great on all devices
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: Next.js 15, React 19, TypeScript
|
||||
- **Styling**: Tailwind CSS, shadcn/ui components
|
||||
- **Database**: PostgreSQL with Drizzle ORM
|
||||
- **Authentication**: NextAuth.js
|
||||
- **Icons**: Lucide React
|
||||
- **Package Manager**: Bun
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+ or Bun
|
||||
- PostgreSQL database
|
||||
- Git
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd opengifame
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
3. **Set up environment variables**
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Edit `.env.local` and configure:
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL="postgres://postgres:hackme@localhost:5432/opengifame"
|
||||
|
||||
# NextAuth
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="your-secret-key-here"
|
||||
|
||||
# OAuth providers (optional)
|
||||
GITHUB_CLIENT_ID="your-github-client-id"
|
||||
GITHUB_CLIENT_SECRET="your-github-client-secret"
|
||||
GOOGLE_CLIENT_ID="your-google-client-id"
|
||||
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
||||
```
|
||||
|
||||
4. **Set up the database**
|
||||
|
||||
Create the PostgreSQL database:
|
||||
```sql
|
||||
CREATE DATABASE opengifame;
|
||||
```
|
||||
|
||||
Run the database migration:
|
||||
```bash
|
||||
bun run db:push
|
||||
```
|
||||
|
||||
5. **Start the development server**
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
|
||||
## Database Schema
|
||||
|
||||
The application uses the following main tables:
|
||||
|
||||
- **users** - User accounts (NextAuth)
|
||||
- **images** - Uploaded images with metadata
|
||||
- **votes** - User votes on images (upvote/downvote)
|
||||
- **comments** - Comments on images with threading support
|
||||
- **tags** - Image tags
|
||||
- **image_tags** - Many-to-many relationship between images and tags
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `POST /api/images/upload` - Upload a new image
|
||||
- `POST /api/images/vote` - Vote on an image
|
||||
- `GET/POST /api/auth/[...nextauth]` - NextAuth endpoints
|
||||
|
||||
## Scripts
|
||||
|
||||
- `bun run dev` - Start development server
|
||||
- `bun run build` - Build for production
|
||||
- `bun run start` - Start production server
|
||||
- `bun run lint` - Run ESLint
|
||||
- `bun run db:generate` - Generate database migrations
|
||||
- `bun run db:push` - Push schema changes to database
|
||||
- `bun run db:studio` - Open Drizzle Studio
|
||||
|
||||
## OAuth Setup (Optional)
|
||||
|
||||
### GitHub OAuth
|
||||
|
||||
1. Go to GitHub Settings > Developer settings > OAuth Apps
|
||||
2. Create a new OAuth App with:
|
||||
- Homepage URL: `http://localhost:3000`
|
||||
- Authorization callback URL: `http://localhost:3000/api/auth/callback/github`
|
||||
3. Add the Client ID and Secret to your `.env.local`
|
||||
|
||||
### Google OAuth
|
||||
|
||||
1. Go to the Google Cloud Console
|
||||
2. Create a new project or select an existing one
|
||||
3. Enable the Google+ API
|
||||
4. Create OAuth 2.0 credentials with:
|
||||
- Authorized JavaScript origins: `http://localhost:3000`
|
||||
- Authorized redirect URIs: `http://localhost:3000/api/auth/callback/google`
|
||||
5. Add the Client ID and Secret to your `.env.local`
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js app directory
|
||||
│ ├── api/ # API routes
|
||||
│ ├── upload/ # Upload page
|
||||
│ ├── trending/ # Trending page
|
||||
│ └── page.tsx # Home page
|
||||
├── components/ # React components
|
||||
│ ├── ui/ # shadcn/ui components
|
||||
│ ├── header.tsx # Navigation header
|
||||
│ └── image-card.tsx # Image display component
|
||||
├── lib/ # Utilities and configuration
|
||||
│ ├── db/ # Database configuration and schema
|
||||
│ ├── auth.ts # NextAuth configuration
|
||||
│ └── utils.ts # Utility functions
|
||||
└── types/ # TypeScript type definitions
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Run tests and linting
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
This project is open source and available under the [MIT License](LICENSE).
|
||||
|
||||
## Deployment
|
||||
|
||||
The application can be deployed to any platform that supports Next.js:
|
||||
|
||||
- **Vercel** (recommended)
|
||||
- **Netlify**
|
||||
- **Railway**
|
||||
- **Docker**
|
||||
|
||||
Make sure to:
|
||||
1. Set up environment variables in your deployment platform
|
||||
2. Configure a PostgreSQL database
|
||||
3. Set up OAuth providers for production URLs
|
||||
|
||||
## Support
|
||||
|
||||
If you have any questions or issues, please open an issue on GitHub.
|
||||
|
||||
@@ -4,15 +4,18 @@
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui"
|
||||
}
|
||||
}
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import { type Config } from "drizzle-kit";
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
import { env } from "@/env";
|
||||
|
||||
export default {
|
||||
schema: "./src/server/db/schema.ts",
|
||||
dialect: "postgresql",
|
||||
export default defineConfig({
|
||||
schema: './src/lib/db/schema.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: env.DATABASE_URL,
|
||||
url: process.env.DATABASE_URL || 'postgres://postgres:hackme@localhost:5432/opengifame',
|
||||
},
|
||||
tablesFilter: ["*"],
|
||||
} satisfies Config;
|
||||
});
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS "accounts" (
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"type" varchar(255) NOT NULL,
|
||||
"provider" varchar(255) NOT NULL,
|
||||
"provider_account_id" varchar(255) NOT NULL,
|
||||
"refresh_token" text,
|
||||
"access_token" text,
|
||||
"expires_at" integer,
|
||||
"token_type" varchar(255),
|
||||
"scope" varchar(255),
|
||||
"id_token" text,
|
||||
"session_state" varchar(255),
|
||||
CONSTRAINT "accounts_provider_provider_account_id_pk" PRIMARY KEY("provider","provider_account_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "posts" (
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
"title" varchar(256),
|
||||
"description" varchar,
|
||||
"tags" text[],
|
||||
"filepath" varchar(256),
|
||||
"created_by" varchar(255) NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
CONSTRAINT "posts_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "sessions" (
|
||||
"session_token" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"expires" timestamp with time zone NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "users" (
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"name" varchar(255),
|
||||
"email" varchar(255) NOT NULL,
|
||||
"email_verified" timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
"image" varchar(255),
|
||||
"password" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "verification_tokens" (
|
||||
"identifier" varchar(255) NOT NULL,
|
||||
"token" varchar(255) NOT NULL,
|
||||
"expires" timestamp with time zone NOT NULL,
|
||||
CONSTRAINT "verification_tokens_identifier_token_pk" PRIMARY KEY("identifier","token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "posts" ADD CONSTRAINT "posts_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "account_user_id_idx" ON "accounts" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "image_user_id_idx" ON "posts" USING btree ("created_by");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "image_slug_idx" ON "posts" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "session_user_id_idx" ON "sessions" USING btree ("user_id");
|
||||
95
drizzle/0000_worried_blade.sql
Normal file
@@ -0,0 +1,95 @@
|
||||
CREATE TABLE "account" (
|
||||
"userId" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"providerAccountId" text NOT NULL,
|
||||
"refresh_token" text,
|
||||
"access_token" text,
|
||||
"expires_at" integer,
|
||||
"token_type" text,
|
||||
"scope" text,
|
||||
"id_token" text,
|
||||
"session_state" text,
|
||||
CONSTRAINT "account_provider_providerAccountId_pk" PRIMARY KEY("provider","providerAccountId")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "comments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"image_id" uuid NOT NULL,
|
||||
"author_id" text NOT NULL,
|
||||
"parent_id" uuid,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "image_tags" (
|
||||
"image_id" uuid NOT NULL,
|
||||
"tag_id" uuid NOT NULL,
|
||||
CONSTRAINT "image_tags_image_id_tag_id_pk" PRIMARY KEY("image_id","tag_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "images" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"description" text,
|
||||
"filename" text NOT NULL,
|
||||
"original_name" text NOT NULL,
|
||||
"mime_type" text NOT NULL,
|
||||
"size" integer NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"uploaded_by" text NOT NULL,
|
||||
"upvotes" integer DEFAULT 0 NOT NULL,
|
||||
"downvotes" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "session" (
|
||||
"sessionToken" text PRIMARY KEY NOT NULL,
|
||||
"userId" text NOT NULL,
|
||||
"expires" timestamp NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tags" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"created_by" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "tags_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text,
|
||||
"email" text NOT NULL,
|
||||
"emailVerified" timestamp,
|
||||
"image" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "verificationToken" (
|
||||
"identifier" text NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"expires" timestamp NOT NULL,
|
||||
CONSTRAINT "verificationToken_identifier_token_pk" PRIMARY KEY("identifier","token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "votes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"image_id" uuid NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"is_upvote" boolean NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_image_id_images_id_fk" FOREIGN KEY ("image_id") REFERENCES "public"."images"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_user_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_parent_id_comments_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "image_tags" ADD CONSTRAINT "image_tags_image_id_images_id_fk" FOREIGN KEY ("image_id") REFERENCES "public"."images"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "image_tags" ADD CONSTRAINT "image_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "images" ADD CONSTRAINT "images_uploaded_by_user_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "session" ADD CONSTRAINT "session_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tags" ADD CONSTRAINT "tags_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "votes" ADD CONSTRAINT "votes_image_id_images_id_fk" FOREIGN KEY ("image_id") REFERENCES "public"."images"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "votes" ADD CONSTRAINT "votes_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -1,13 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS "votes" (
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"up" boolean DEFAULT true NOT NULL,
|
||||
"created_by" varchar(255) NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "votes" ADD CONSTRAINT "votes_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -1,6 +0,0 @@
|
||||
ALTER TABLE "votes" ADD COLUMN "post_id" varchar(255) NOT NULL;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "votes" ADD CONSTRAINT "votes_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -1,7 +0,0 @@
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "public"."post-visibility" AS ENUM('public', 'private', 'link');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "posts" ADD COLUMN "visibility" "post-visibility" DEFAULT 'public' NOT NULL;
|
||||
@@ -1,34 +1,34 @@
|
||||
{
|
||||
"id": "df0e1372-accb-4875-885d-c19970e69eae",
|
||||
"id": "473ec1da-b564-4012-a1ad-e856f6e2c23d",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.accounts": {
|
||||
"name": "accounts",
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "varchar(255)",
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(255)",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "varchar(255)",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_account_id": {
|
||||
"name": "provider_account_id",
|
||||
"type": "varchar(255)",
|
||||
"providerAccountId": {
|
||||
"name": "providerAccountId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
@@ -52,13 +52,13 @@
|
||||
},
|
||||
"token_type": {
|
||||
"name": "token_type",
|
||||
"type": "varchar(255)",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "varchar(255)",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
@@ -70,267 +70,445 @@
|
||||
},
|
||||
"session_state": {
|
||||
"name": "session_state",
|
||||
"type": "varchar(255)",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"account_user_id_idx": {
|
||||
"name": "account_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"accounts_user_id_users_id_fk": {
|
||||
"name": "accounts_user_id_users_id_fk",
|
||||
"tableFrom": "accounts",
|
||||
"tableTo": "users",
|
||||
"account_userId_user_id_fk": {
|
||||
"name": "account_userId_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"accounts_provider_provider_account_id_pk": {
|
||||
"name": "accounts_provider_provider_account_id_pk",
|
||||
"account_provider_providerAccountId_pk": {
|
||||
"name": "account_provider_providerAccountId_pk",
|
||||
"columns": [
|
||||
"provider",
|
||||
"provider_account_id"
|
||||
"providerAccountId"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.posts": {
|
||||
"name": "posts",
|
||||
"public.comments": {
|
||||
"name": "comments",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image_id": {
|
||||
"name": "image_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"author_id": {
|
||||
"name": "author_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"comments_image_id_images_id_fk": {
|
||||
"name": "comments_image_id_images_id_fk",
|
||||
"tableFrom": "comments",
|
||||
"tableTo": "images",
|
||||
"columnsFrom": [
|
||||
"image_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"comments_author_id_user_id_fk": {
|
||||
"name": "comments_author_id_user_id_fk",
|
||||
"tableFrom": "comments",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"author_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"comments_parent_id_comments_id_fk": {
|
||||
"name": "comments_parent_id_comments_id_fk",
|
||||
"tableFrom": "comments",
|
||||
"tableTo": "comments",
|
||||
"columnsFrom": [
|
||||
"parent_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.image_tags": {
|
||||
"name": "image_tags",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"image_id": {
|
||||
"name": "image_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"tag_id": {
|
||||
"name": "tag_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"image_tags_image_id_images_id_fk": {
|
||||
"name": "image_tags_image_id_images_id_fk",
|
||||
"tableFrom": "image_tags",
|
||||
"tableTo": "images",
|
||||
"columnsFrom": [
|
||||
"image_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"image_tags_tag_id_tags_id_fk": {
|
||||
"name": "image_tags_tag_id_tags_id_fk",
|
||||
"tableFrom": "image_tags",
|
||||
"tableTo": "tags",
|
||||
"columnsFrom": [
|
||||
"tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"image_tags_image_id_tag_id_pk": {
|
||||
"name": "image_tags_image_id_tag_id_pk",
|
||||
"columns": [
|
||||
"image_id",
|
||||
"tag_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.images": {
|
||||
"name": "images",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "varchar(256)",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "varchar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"type": "text[]",
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true
|
||||
},
|
||||
"filepath": {
|
||||
"name": "filepath",
|
||||
"type": "varchar(256)",
|
||||
"original_name": {
|
||||
"name": "original_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"uploaded_by": {
|
||||
"name": "uploaded_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"upvotes": {
|
||||
"name": "upvotes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"downvotes": {
|
||||
"name": "downvotes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"images_uploaded_by_user_id_fk": {
|
||||
"name": "images_uploaded_by_user_id_fk",
|
||||
"tableFrom": "images",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"uploaded_by"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"sessionToken": {
|
||||
"name": "sessionToken",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_userId_user_id_fk": {
|
||||
"name": "session_userId_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.tags": {
|
||||
"name": "tags",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "varchar(255)",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"image_user_id_idx": {
|
||||
"name": "image_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_by",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"image_slug_idx": {
|
||||
"name": "image_slug_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"posts_created_by_users_id_fk": {
|
||||
"name": "posts_created_by_users_id_fk",
|
||||
"tableFrom": "posts",
|
||||
"tableTo": "users",
|
||||
"tags_created_by_user_id_fk": {
|
||||
"name": "tags_created_by_user_id_fk",
|
||||
"tableFrom": "tags",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"created_by"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"posts_slug_unique": {
|
||||
"name": "posts_slug_unique",
|
||||
"tags_name_unique": {
|
||||
"name": "tags_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
"name"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"session_token": {
|
||||
"name": "session_token",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"session_user_id_idx": {
|
||||
"name": "session_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "varchar(255)",
|
||||
"emailVerified": {
|
||||
"name": "emailVerified",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
@@ -339,27 +517,30 @@
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification_tokens": {
|
||||
"name": "verification_tokens",
|
||||
"public.verificationToken": {
|
||||
"name": "verificationToken",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "varchar(255)",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "varchar(255)",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
@@ -367,20 +548,98 @@
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"verification_tokens_identifier_token_pk": {
|
||||
"name": "verification_tokens_identifier_token_pk",
|
||||
"verificationToken_identifier_token_pk": {
|
||||
"name": "verificationToken_identifier_token_pk",
|
||||
"columns": [
|
||||
"identifier",
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.votes": {
|
||||
"name": "votes",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"image_id": {
|
||||
"name": "image_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_upvote": {
|
||||
"name": "is_upvote",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"votes_image_id_images_id_fk": {
|
||||
"name": "votes_image_id_images_id_fk",
|
||||
"tableFrom": "votes",
|
||||
"tableTo": "images",
|
||||
"columnsFrom": [
|
||||
"image_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"votes_user_id_user_id_fk": {
|
||||
"name": "votes_user_id_user_id_fk",
|
||||
"tableFrom": "votes",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
|
||||
@@ -1,445 +0,0 @@
|
||||
{
|
||||
"id": "e8b4ac19-4d79-4886-ad5e-eb19f551efde",
|
||||
"prevId": "df0e1372-accb-4875-885d-c19970e69eae",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.accounts": {
|
||||
"name": "accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_account_id": {
|
||||
"name": "provider_account_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"token_type": {
|
||||
"name": "token_type",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"session_state": {
|
||||
"name": "session_state",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"account_user_id_idx": {
|
||||
"name": "account_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"accounts_user_id_users_id_fk": {
|
||||
"name": "accounts_user_id_users_id_fk",
|
||||
"tableFrom": "accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"accounts_provider_provider_account_id_pk": {
|
||||
"name": "accounts_provider_provider_account_id_pk",
|
||||
"columns": [
|
||||
"provider",
|
||||
"provider_account_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.posts": {
|
||||
"name": "posts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "varchar",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"filepath": {
|
||||
"name": "filepath",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"image_user_id_idx": {
|
||||
"name": "image_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_by",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"image_slug_idx": {
|
||||
"name": "image_slug_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"posts_created_by_users_id_fk": {
|
||||
"name": "posts_created_by_users_id_fk",
|
||||
"tableFrom": "posts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"created_by"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"posts_slug_unique": {
|
||||
"name": "posts_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"session_token": {
|
||||
"name": "session_token",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"session_user_id_idx": {
|
||||
"name": "session_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.verification_tokens": {
|
||||
"name": "verification_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"verification_tokens_identifier_token_pk": {
|
||||
"name": "verification_tokens_identifier_token_pk",
|
||||
"columns": [
|
||||
"identifier",
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.votes": {
|
||||
"name": "votes",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"up": {
|
||||
"name": "up",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"votes_created_by_users_id_fk": {
|
||||
"name": "votes_created_by_users_id_fk",
|
||||
"tableFrom": "votes",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"created_by"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -1,464 +0,0 @@
|
||||
{
|
||||
"id": "2ce7d201-995a-4c67-9041-93767b913d60",
|
||||
"prevId": "e8b4ac19-4d79-4886-ad5e-eb19f551efde",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.accounts": {
|
||||
"name": "accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_account_id": {
|
||||
"name": "provider_account_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"token_type": {
|
||||
"name": "token_type",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"session_state": {
|
||||
"name": "session_state",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"account_user_id_idx": {
|
||||
"name": "account_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"accounts_user_id_users_id_fk": {
|
||||
"name": "accounts_user_id_users_id_fk",
|
||||
"tableFrom": "accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"accounts_provider_provider_account_id_pk": {
|
||||
"name": "accounts_provider_provider_account_id_pk",
|
||||
"columns": [
|
||||
"provider",
|
||||
"provider_account_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.posts": {
|
||||
"name": "posts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "varchar",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"filepath": {
|
||||
"name": "filepath",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"image_user_id_idx": {
|
||||
"name": "image_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_by",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"image_slug_idx": {
|
||||
"name": "image_slug_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"posts_created_by_users_id_fk": {
|
||||
"name": "posts_created_by_users_id_fk",
|
||||
"tableFrom": "posts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"created_by"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"posts_slug_unique": {
|
||||
"name": "posts_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"session_token": {
|
||||
"name": "session_token",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"session_user_id_idx": {
|
||||
"name": "session_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.verification_tokens": {
|
||||
"name": "verification_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"verification_tokens_identifier_token_pk": {
|
||||
"name": "verification_tokens_identifier_token_pk",
|
||||
"columns": [
|
||||
"identifier",
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.votes": {
|
||||
"name": "votes",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"post_id": {
|
||||
"name": "post_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"up": {
|
||||
"name": "up",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"votes_post_id_posts_id_fk": {
|
||||
"name": "votes_post_id_posts_id_fk",
|
||||
"tableFrom": "votes",
|
||||
"tableTo": "posts",
|
||||
"columnsFrom": [
|
||||
"post_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"votes_created_by_users_id_fk": {
|
||||
"name": "votes_created_by_users_id_fk",
|
||||
"tableFrom": "votes",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"created_by"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
{
|
||||
"id": "b1930da4-c6a4-4cda-ba3b-f30e01c1b174",
|
||||
"prevId": "2ce7d201-995a-4c67-9041-93767b913d60",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.accounts": {
|
||||
"name": "accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_account_id": {
|
||||
"name": "provider_account_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"token_type": {
|
||||
"name": "token_type",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"session_state": {
|
||||
"name": "session_state",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"account_user_id_idx": {
|
||||
"name": "account_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"accounts_user_id_users_id_fk": {
|
||||
"name": "accounts_user_id_users_id_fk",
|
||||
"tableFrom": "accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"accounts_provider_provider_account_id_pk": {
|
||||
"name": "accounts_provider_provider_account_id_pk",
|
||||
"columns": [
|
||||
"provider",
|
||||
"provider_account_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.posts": {
|
||||
"name": "posts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "varchar",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"filepath": {
|
||||
"name": "filepath",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"visibility": {
|
||||
"name": "visibility",
|
||||
"type": "post-visibility",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'public'"
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"image_user_id_idx": {
|
||||
"name": "image_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_by",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"image_slug_idx": {
|
||||
"name": "image_slug_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"posts_created_by_users_id_fk": {
|
||||
"name": "posts_created_by_users_id_fk",
|
||||
"tableFrom": "posts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"created_by"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"posts_slug_unique": {
|
||||
"name": "posts_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"session_token": {
|
||||
"name": "session_token",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"session_user_id_idx": {
|
||||
"name": "session_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.verification_tokens": {
|
||||
"name": "verification_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"verification_tokens_identifier_token_pk": {
|
||||
"name": "verification_tokens_identifier_token_pk",
|
||||
"columns": [
|
||||
"identifier",
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.votes": {
|
||||
"name": "votes",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"post_id": {
|
||||
"name": "post_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"up": {
|
||||
"name": "up",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"votes_post_id_posts_id_fk": {
|
||||
"name": "votes_post_id_posts_id_fk",
|
||||
"tableFrom": "votes",
|
||||
"tableTo": "posts",
|
||||
"columnsFrom": [
|
||||
"post_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"votes_created_by_users_id_fk": {
|
||||
"name": "votes_created_by_users_id_fk",
|
||||
"tableFrom": "votes",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"created_by"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.post-visibility": {
|
||||
"name": "post-visibility",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"public",
|
||||
"private",
|
||||
"link"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -5,29 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1726870110731,
|
||||
"tag": "0000_windy_wrecking_crew",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1726870450874,
|
||||
"tag": "0001_warm_jimmy_woo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1726870854367,
|
||||
"tag": "0002_sloppy_starfox",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1727802113532,
|
||||
"tag": "0003_mean_thing",
|
||||
"when": 1751648196867,
|
||||
"tag": "0000_worried_blade",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
16
eslint.config.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { Config } from "jest";
|
||||
const config: Config = {
|
||||
verbose: true,
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
|
||||
* for Docker builds.
|
||||
*/
|
||||
await import("./src/env.js");
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
hostname: "avatars.githubusercontent.com",
|
||||
},
|
||||
{
|
||||
hostname: "opengifame.dev.fergl.ie",
|
||||
},
|
||||
{
|
||||
hostname: "cloudflare-ipfs.com",
|
||||
},
|
||||
{
|
||||
hostname: "localhost",
|
||||
},
|
||||
],
|
||||
},
|
||||
// async rewrites() {
|
||||
// return [
|
||||
// {
|
||||
// source: "/i/:path*",
|
||||
// destination: "https://your-custom-image-location.com/:path*", // Replace with your custom location
|
||||
// },
|
||||
// ];
|
||||
// },
|
||||
};
|
||||
|
||||
export default config;
|
||||
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
12206
package-lock.json
generated
155
package.json
@@ -2,134 +2,49 @@
|
||||
"name": "opengifame",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev:ssl": "NODE_ENV=development next dev -p 3002 --turbo & local-ssl-proxy --config ./ssl-proxy.json",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"dev:turbo": "NODE_ENV=development next dev -p 3002 --turbo & local-ssl-proxy --config ./ssl-proxy.json",
|
||||
"dev": "NODE_ENV=development next dev -p 3002 & local-ssl-proxy --config ./ssl-proxy.json",
|
||||
"dev:plain": "next dev",
|
||||
"lint": "next lint",
|
||||
"start": "next start"
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/drizzle-adapter": "^1.5.3",
|
||||
"@headlessui/react": "^2.1.8",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.1",
|
||||
"@radix-ui/react-collapsible": "^1.1.0",
|
||||
"@radix-ui/react-context-menu": "^2.2.1",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-hover-card": "^1.1.1",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-menubar": "^1.1.1",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.2.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"@trpc/client": "^11.0.0-rc.446",
|
||||
"@trpc/react-query": "^11.0.0-rc.446",
|
||||
"@trpc/server": "^11.0.0-rc.446",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/http-status-codes": "^1.2.0",
|
||||
"@types/lodash": "^4.17.9",
|
||||
"@types/loglevel": "^1.6.3",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/react-copy-to-clipboard": "^5.0.7",
|
||||
"@types/react-responsive-masonry": "^2.1.3",
|
||||
"bcrypt": "^5.1.1",
|
||||
"chalk": "^5.3.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"@auth/drizzle-adapter": "^1.10.0",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-toast": "^1.2.14",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"framer-motion": "^11.9.0",
|
||||
"geist": "^1.3.1",
|
||||
"http-status-codes": "^2.3.0",
|
||||
"input-otp": "^1.2.4",
|
||||
"lodash": "^4.17.21",
|
||||
"loglevel": "^1.9.2",
|
||||
"loglevel-plugin-prefix": "^0.8.4",
|
||||
"lucide-react": "^0.446.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"next": "^14.2.13",
|
||||
"next-auth": "^4.24.8",
|
||||
"next-images": "^1.8.5",
|
||||
"next-themes": "^0.3.0",
|
||||
"postgres": "^3.4.4",
|
||||
"react": "^18.3.1",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-day-picker": "9.1.3",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-resizable-panels": "^2.1.4",
|
||||
"react-responsive-masonry": "^2.3.0",
|
||||
"recharts": "^2.12.7",
|
||||
"server-only": "^0.0.1",
|
||||
"sonner": "^1.5.0",
|
||||
"superjson": "^2.2.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.0.0",
|
||||
"zod": "^3.23.8"
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "15.3.5",
|
||||
"next-auth": "^4.24.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"postgres": "^3.4.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/eslint": "^8.56.10",
|
||||
|
||||
"@typescript-eslint/eslint-plugin": "^8.1.0",
|
||||
"@typescript-eslint/parser": "^8.1.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^14.2.4",
|
||||
"eslint-plugin-drizzle": "^0.2.3",
|
||||
|
||||
|
||||
|
||||
"@faker-js/faker": "^9.0.3",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@types/bun": "latest",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/react": "^18.3.10",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"drizzle-kit": "^0.24.2",
|
||||
"jest": "^29.7.0",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.8",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.37.0"
|
||||
},
|
||||
"module": "index.ts"
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.5",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,6 +0,0 @@
|
||||
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
||||
const config = {
|
||||
plugins: ["prettier-plugin-tailwindcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
22
public/favicon.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background circle -->
|
||||
<circle cx="16" cy="16" r="15" fill="#3B82F6" stroke="#2563EB" stroke-width="2"/>
|
||||
|
||||
<!-- Camera/Gallery icon -->
|
||||
<g transform="translate(6, 8)">
|
||||
<!-- Camera body -->
|
||||
<rect x="2" y="4" width="16" height="12" rx="2" fill="white" opacity="0.9"/>
|
||||
|
||||
<!-- Camera lens -->
|
||||
<circle cx="10" cy="10" r="3.5" fill="none" stroke="#3B82F6" stroke-width="1.5"/>
|
||||
<circle cx="10" cy="10" r="2" fill="#3B82F6"/>
|
||||
|
||||
<!-- Camera flash/viewfinder -->
|
||||
<rect x="4" y="2" width="4" height="2" rx="1" fill="white" opacity="0.9"/>
|
||||
|
||||
<!-- Gallery indicator (small rectangles) -->
|
||||
<rect x="14.5" y="5.5" width="2" height="1.5" rx="0.3" fill="#3B82F6" opacity="0.6"/>
|
||||
<rect x="14.5" y="7.5" width="2" height="1.5" rx="0.3" fill="#3B82F6" opacity="0.8"/>
|
||||
<rect x="14.5" y="9.5" width="2" height="1.5" rx="0.3" fill="#3B82F6"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 996 B |
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/icon.png
|
Before Width: | Height: | Size: 64 KiB |
@@ -1,15 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<!-- Background -->
|
||||
<rect x="0" y="0" width="100" height="100" rx="20" ry="20" fill="#FF6B6B"/>
|
||||
|
||||
<!-- Abstract prism shape - significantly enlarged -->
|
||||
<path d="M50,10 L90,80 L10,80 Z" fill="white"/>
|
||||
|
||||
<!-- Color refraction lines - adjusted for new size -->
|
||||
<path d="M50,10 L62,80" stroke="#FFD166" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M50,10 L50,80" stroke="#06D6A0" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M50,10 L38,80" stroke="#118AB2" stroke-width="4" stroke-linecap="round"/>
|
||||
|
||||
<!-- Circular highlight - adjusted position and size -->
|
||||
<circle cx="50" cy="35" r="9" fill="#FF6B6B" stroke="white" stroke-width="2.5"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 755 B |
|
Before Width: | Height: | Size: 53 KiB |
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -1,21 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
export PGUSER=postgres
|
||||
export PGPASSWORD=hackme
|
||||
export PGHOST=localhost
|
||||
|
||||
echo Removing migrations
|
||||
rm -rf drizzle
|
||||
echo "Dropping db"
|
||||
|
||||
dropdb -f --if-exists opengifame
|
||||
echo "Creating db"
|
||||
createdb opengifame
|
||||
|
||||
source .env.development
|
||||
bun run db:generate
|
||||
bun run db:push
|
||||
|
||||
rm /srv/dev/opengifame/working/uploads/* -rfv
|
||||
# # bun run src/db/migrate.ts
|
||||
# bun run ./src/server/db/scripts/seed.ts
|
||||
# bun run ./src/server/db/scripts/auth.ts
|
||||
@@ -1,12 +0,0 @@
|
||||
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('974248ec-ae31-4c1a-900d-348432e16e90', 'Frasier "directs" Niles', 'Ham Radio', '{}', '974248ec-ae31-4c1a-900d-348432e16e90.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:26:51.350362 +00:00', '2024-09-18 15:26:51.639000 +00:00', 'frasier-directs-niles');
|
||||
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('18fac61f-d2c4-4248-b74b-5d23e8717bbc', 'Frasier groaning.', 'Frasier & Niles in Nervosa', '{}', '18fac61f-d2c4-4248-b74b-5d23e8717bbc.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:46.438441 +00:00', '2024-09-18 15:27:46.477000 +00:00', 'frasier-groaning');
|
||||
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('56e89352-aa69-4a9e-92c4-5eb3e8e94703', 'Frasier tasting caviar', 'From Roe to Perdition S10E18 ', '{}', '56e89352-aa69-4a9e-92c4-5eb3e8e94703.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:28.599836 +00:00', '2024-09-18 15:27:28.632000 +00:00', 'frasier-tasting-caviar');
|
||||
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('38d3e400-20a3-49be-bf8f-5c4c77ab3a9f', 'Niles gets a class of champagne in the face', 'From Voyage of the Damned', '{}', '38d3e400-20a3-49be-bf8f-5c4c77ab3a9f.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:01.933852 +00:00', '2024-09-18 15:27:01.967000 +00:00', 'niles-gets-a-class-of-champagne-in-the-face');
|
||||
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('4aa77fab-cfe4-4f5d-ba62-c674b43b09c8', 'I miss being unapproachable', 'From Good Grief', '{}', '4aa77fab-cfe4-4f5d-ba62-c674b43b09c8.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:18.617006 +00:00', '2024-09-18 15:27:18.651000 +00:00', 'i-miss-being-unapproachable');
|
||||
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('062edc3f-2d3c-4d97-a569-f41804d6d352', 'I''m having one now', 'Niles has never had an unexpressed thought', '{niles,frasier}', '062edc3f-2d3c-4d97-a569-f41804d6d352.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:59.258519 +00:00', '2024-09-18 15:27:59.283000 +00:00', 'i-m-having-one-now');
|
||||
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('ced6dc13-10b4-43d7-8072-0db2091840d7', 'Wounded!!!', 'Frasier is wounded', '{frasier,wounded}', 'ced6dc13-10b4-43d7-8072-0db2091840d7.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:37.875584 +00:00', '2024-09-18 15:27:37.914000 +00:00', 'wounded');
|
||||
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('13c54e08-2fd6-4810-982f-14b701e38b5f', 'Rrrrreally.', 'Guy checks out Niles'' bottom', '{}', '13c54e08-2fd6-4810-982f-14b701e38b5f.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:13.414832 +00:00', '2024-09-18 15:27:13.442000 +00:00', 'rrrrreally');
|
||||
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('d23df48f-2b99-4b29-90fd-2f3cff205010', 'Successful High Five', 'Niles & Frasier successfully complete a high five (including behind the back down low).', '{}', 'd23df48f-2b99-4b29-90fd-2f3cff205010.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:51.131650 +00:00', '2024-09-18 15:27:51.152000 +00:00', 'successful-high-five');
|
||||
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('0ccfccd4-c24f-42a0-9864-2cb8d230bb16', 'Niles clapping', 'Niles is excited', '{}', '0ccfccd4-c24f-42a0-9864-2cb8d230bb16.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:33.156638 +00:00', '2024-09-18 15:27:33.220000 +00:00', 'niles-clapping');
|
||||
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('4a208725-453d-4d0c-b620-cc82b7192ea8', 'I just want to die!!', '', '{}', '4a208725-453d-4d0c-b620-cc82b7192ea8.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:07.374464 +00:00', '2024-09-18 15:27:07.396000 +00:00', 'i-just-want-to-die');
|
||||
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('4a2a52a2-85eb-417c-bdb8-a1d0101df17f', 'Some boys go to college.', 'Oh, some boys go to college, but we think they''re all wussies, cuz they get all the knowledge, and we get all the...', '{}', '4a2a52a2-85eb-417c-bdb8-a1d0101df17f.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:42.138300 +00:00', '2024-09-18 15:27:42.175000 +00:00', 'some-boys-go-to-college');
|
||||
@@ -1,12 +0,0 @@
|
||||
import PostPage from "@/components/pages/post/post-page";
|
||||
import { api } from "@/trpc/server";
|
||||
|
||||
type PostRouteParams = {
|
||||
slug: string;
|
||||
};
|
||||
const PostRoute = async ({ params }: { params: PostRouteParams }) => {
|
||||
const post = await api.post.getBySlug({ slug: params.slug });
|
||||
return <PostPage post={post} />;
|
||||
};
|
||||
|
||||
export default PostRoute;
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
|
||||
import UploadPage from "@/components/pages/upload-page";
|
||||
import { ClipboardContext } from "@/lib/clipboard/clipboard-context";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import GenericError from "@/components/errors/generic-error";
|
||||
import Loading from "@/components/widgets/loading";
|
||||
|
||||
const Upload = () => {
|
||||
const clipboardContext = React.useContext(ClipboardContext);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const path = useSearchParams();
|
||||
const c = path.get("c") || "";
|
||||
|
||||
React.useEffect(() => {
|
||||
console.log("page", "c", c);
|
||||
console.log("page", "f", clipboardContext?.file);
|
||||
if (c === "1" && clipboardContext?.file) {
|
||||
setLoading(false);
|
||||
console.log("page", "set-file", clipboardContext?.file);
|
||||
} else {
|
||||
if (c !== "1") {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [c, clipboardContext?.file]);
|
||||
|
||||
const renderPage = () => {
|
||||
if (
|
||||
(c === "1" && clipboardContext?.file != null && !loading) ||
|
||||
c !== "1"
|
||||
) {
|
||||
return <UploadPage pastedImage={clipboardContext?.file ?? undefined} />;
|
||||
}
|
||||
if (c === "1" && loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
return (
|
||||
<GenericError
|
||||
code={500}
|
||||
title="Ooopsies"
|
||||
text="Unable to find any image data, please try again."
|
||||
/>
|
||||
);
|
||||
};
|
||||
return renderPage();
|
||||
};
|
||||
|
||||
export default Upload;
|
||||
@@ -1,27 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function AuthLayout({ children }: AuthLayoutProps) {
|
||||
return (
|
||||
<div className="container flex h-screen w-screen flex-col items-center ">
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"absolute left-4 top-4 mt-8 md:left-8 md:top-8",
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<Icons.chevronLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</>
|
||||
</Link>
|
||||
<div className="mt-8">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import RegistrationForm from "@/components/forms/auth/registration-form";
|
||||
import SocialLogin from "@/components/widgets/login/social-login-button";
|
||||
import Link from "next/link";
|
||||
import { Icons } from "@/components/icons";
|
||||
|
||||
const RegisterPage: React.FC = () => {
|
||||
return (
|
||||
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||
<div className="flex flex-col space-y-2 text-center">
|
||||
<Icons.logo className="mx-auto h-6 w-6" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Welcome</h1>
|
||||
<p className="text-sm text-muted-foreground">Register for an account</p>
|
||||
</div>
|
||||
<RegistrationForm />
|
||||
<SocialLogin />
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
<Link
|
||||
href="/signin"
|
||||
className="hover:text-brand underline underline-offset-4"
|
||||
>
|
||||
Already have an account? Login?
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
@@ -1,32 +0,0 @@
|
||||
"use client";
|
||||
import SocialLogin from "@/components/widgets/login/social-login-button";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { Icons } from "@/components/icons";
|
||||
import SignInForm from "@/components/forms/auth/signin-form";
|
||||
|
||||
const SignInPage = () => {
|
||||
return (
|
||||
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||
<div className="flex flex-col space-y-2 text-center">
|
||||
<Icons.logo className="mx-auto h-6 w-6" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Welcome back</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your email to sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
<SignInForm />
|
||||
<SocialLogin />
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
<Link
|
||||
href="/register"
|
||||
className="hover:text-brand underline underline-offset-4"
|
||||
>
|
||||
Don't have an account? Sign Up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignInPage;
|
||||
@@ -1,11 +0,0 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { clipboardImageToFile } from "@/lib/clipboard/converters";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export default function SiteLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import NextAuth from "next-auth";
|
||||
import NextAuth from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
import { authOptions } from "@/server/auth";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const handler = NextAuth(authOptions);
|
||||
export { handler as GET, handler as POST };
|
||||
|
||||
export const GET = handler;
|
||||
export const POST = handler;
|
||||
|
||||
95
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import bcrypt from 'bcryptjs';
|
||||
// import { db } from '@/lib/db';
|
||||
// import { users } from '@/lib/db/schema';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { email, password, username } = await request.json();
|
||||
|
||||
// Validation
|
||||
if (!email || !password || !username) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email, password, and username are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (username.includes(' ')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username cannot contain spaces' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (username.length > 32) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username must be less than 32 characters' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password must be at least 6 characters long' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Please enter a valid email address' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Check if user already exists when DB is connected
|
||||
// const existingUser = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
||||
// const existingUsername = await db.select().from(users).where(eq(users.username, username)).limit(1);
|
||||
|
||||
// if (existingUser.length > 0) {
|
||||
// return NextResponse.json(
|
||||
// { error: 'User with this email already exists' },
|
||||
// { status: 400 }
|
||||
// );
|
||||
// }
|
||||
|
||||
// if (existingUsername.length > 0) {
|
||||
// return NextResponse.json(
|
||||
// { error: 'Username is already taken' },
|
||||
// { status: 400 }
|
||||
// );
|
||||
// }
|
||||
|
||||
// Hash password
|
||||
const saltRounds = 12;
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// TODO: Create user in database when DB is connected
|
||||
// const newUser = await db.insert(users).values({
|
||||
// email,
|
||||
// username,
|
||||
// password: hashedPassword,
|
||||
// name: username, // Use username as display name initially
|
||||
// }).returning();
|
||||
|
||||
// For now, return success without actually creating user
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Registration successful! Please sign in.',
|
||||
// TODO: Remove this note when DB is connected
|
||||
note: 'Note: User not actually created until database is connected'
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
107
src/app/api/images/upload/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { images, tags, imageTags } from '@/lib/db/schema';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
const title = formData.get('title') as string;
|
||||
const description = formData.get('description') as string;
|
||||
const tagsInput = formData.get('tags') as string;
|
||||
|
||||
if (!file || !title) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return NextResponse.json({ error: 'File must be an image' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now();
|
||||
const extension = file.name.split('.').pop();
|
||||
const filename = `${timestamp}-${Math.random().toString(36).substring(2)}.${extension}`;
|
||||
|
||||
// Create upload directory if it doesn't exist
|
||||
const uploadDir = join(process.cwd(), 'public', 'uploads');
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
|
||||
// Save file
|
||||
const filepath = join(uploadDir, filename);
|
||||
const bytes = await file.arrayBuffer();
|
||||
await writeFile(filepath, Buffer.from(bytes));
|
||||
|
||||
// Create image record
|
||||
const imageUrl = `/uploads/${filename}`;
|
||||
const [newImage] = await db
|
||||
.insert(images)
|
||||
.values({
|
||||
title,
|
||||
description: description || null,
|
||||
filename,
|
||||
originalName: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
url: imageUrl,
|
||||
uploadedBy: session.user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Process tags
|
||||
if (tagsInput) {
|
||||
const tagNames = tagsInput
|
||||
.split(',')
|
||||
.map(tag => tag.trim().toLowerCase())
|
||||
.filter(tag => tag.length > 0);
|
||||
|
||||
for (const tagName of tagNames) {
|
||||
// Check if tag exists, create if not
|
||||
const existingTag = await db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(eq(tags.name, tagName))
|
||||
.limit(1);
|
||||
|
||||
let tagId;
|
||||
if (existingTag.length === 0) {
|
||||
const [newTag] = await db
|
||||
.insert(tags)
|
||||
.values({
|
||||
name: tagName,
|
||||
createdBy: session.user.id,
|
||||
})
|
||||
.returning();
|
||||
tagId = newTag.id;
|
||||
} else {
|
||||
tagId = existingTag[0].id;
|
||||
}
|
||||
|
||||
// Link tag to image
|
||||
await db.insert(imageTags).values({
|
||||
imageId: newImage.id,
|
||||
tagId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: newImage.id,
|
||||
url: imageUrl,
|
||||
message: 'Image uploaded successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
82
src/app/api/images/vote/route.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { votes, images } from '@/lib/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { imageId, isUpvote } = await request.json();
|
||||
|
||||
if (!imageId || typeof isUpvote !== 'boolean') {
|
||||
return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if user already voted on this image
|
||||
const existingVote = await db
|
||||
.select()
|
||||
.from(votes)
|
||||
.where(and(eq(votes.imageId, imageId), eq(votes.userId, session.user.id)))
|
||||
.limit(1);
|
||||
|
||||
if (existingVote.length > 0) {
|
||||
const currentVote = existingVote[0];
|
||||
|
||||
if (currentVote.isUpvote === isUpvote) {
|
||||
// Remove vote if clicking the same vote type
|
||||
await db
|
||||
.delete(votes)
|
||||
.where(eq(votes.id, currentVote.id));
|
||||
} else {
|
||||
// Update vote if changing vote type
|
||||
await db
|
||||
.update(votes)
|
||||
.set({ isUpvote })
|
||||
.where(eq(votes.id, currentVote.id));
|
||||
}
|
||||
} else {
|
||||
// Create new vote
|
||||
await db.insert(votes).values({
|
||||
imageId,
|
||||
userId: session.user.id,
|
||||
isUpvote,
|
||||
});
|
||||
}
|
||||
|
||||
// Get updated vote counts
|
||||
const allVotes = await db
|
||||
.select()
|
||||
.from(votes)
|
||||
.where(eq(votes.imageId, imageId));
|
||||
|
||||
const upvotes = allVotes.filter(v => v.isUpvote).length;
|
||||
const downvotes = allVotes.filter(v => !v.isUpvote).length;
|
||||
|
||||
// Update image vote counts
|
||||
await db
|
||||
.update(images)
|
||||
.set({ upvotes, downvotes })
|
||||
.where(eq(images.id, imageId));
|
||||
|
||||
// Get user's current vote
|
||||
const userVote = await db
|
||||
.select()
|
||||
.from(votes)
|
||||
.where(and(eq(votes.imageId, imageId), eq(votes.userId, session.user.id)))
|
||||
.limit(1);
|
||||
|
||||
return NextResponse.json({
|
||||
upvotes,
|
||||
downvotes,
|
||||
userVote: userVote.length > 0 ? (userVote[0].isUpvote ? 'up' : 'down') : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Vote error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { env } from "@/env";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { createTRPCContext } from "@/server/api/trpc";
|
||||
|
||||
/**
|
||||
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
|
||||
* handling a HTTP request (e.g. when you make requests from Client Components).
|
||||
*/
|
||||
const createContext = async (req: NextRequest) => {
|
||||
return createTRPCContext({
|
||||
headers: req.headers,
|
||||
});
|
||||
};
|
||||
|
||||
const handler = (req: NextRequest) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => createContext(req),
|
||||
onError:
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -1,50 +0,0 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { env } from "@/env";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { db } from "@/server/db";
|
||||
import { posts } from "@/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getServerSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({
|
||||
error: "Unauthorized",
|
||||
status: StatusCodes.UNAUTHORIZED,
|
||||
});
|
||||
}
|
||||
const id = request.nextUrl.searchParams.get("id");
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({
|
||||
error: "No post id in query",
|
||||
status: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
}
|
||||
const formData = await request.formData();
|
||||
const body = Object.fromEntries(formData);
|
||||
const file = (body.image as Blob) || null;
|
||||
|
||||
if (file) {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const extension = path.extname((body.image as File).name);
|
||||
const filePath = `${id}${extension}`;
|
||||
|
||||
fs.writeFileSync(path.resolve(env.UPLOAD_PATH, filePath), buffer);
|
||||
|
||||
await db.update(posts).set({ filePath }).where(eq(posts.id, id));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url: env.NEXT_PUBLIC_SITE_URL + `/i/${filePath}`,
|
||||
});
|
||||
}
|
||||
return NextResponse.json({
|
||||
error: "Cannot find file in form data",
|
||||
status: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
}
|
||||
242
src/app/auth/register/page.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { AlertCircle, CheckCircle } from 'lucide-react';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const [success, setSuccess] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: string[] = [];
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.push('Email is required');
|
||||
} else {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(formData.email)) {
|
||||
newErrors.push('Please enter a valid email address');
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.username) {
|
||||
newErrors.push('Username is required');
|
||||
} else {
|
||||
if (formData.username.includes(' ')) {
|
||||
newErrors.push('Username cannot contain spaces');
|
||||
}
|
||||
if (formData.username.length > 32) {
|
||||
newErrors.push('Username must be less than 32 characters');
|
||||
}
|
||||
if (formData.username.length < 3) {
|
||||
newErrors.push('Username must be at least 3 characters long');
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.push('Password is required');
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.push('Password must be at least 6 characters long');
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.push('Passwords do not match');
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrors([]);
|
||||
setSuccess('');
|
||||
|
||||
const validationErrors = validateForm();
|
||||
if (validationErrors.length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: formData.email,
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setSuccess(data.message);
|
||||
setFormData({ email: '', username: '', password: '', confirmPassword: '' });
|
||||
|
||||
// Redirect to sign-in page after successful registration
|
||||
setTimeout(() => {
|
||||
router.push('/auth/signin');
|
||||
}, 2000);
|
||||
} else {
|
||||
setErrors([data.error || 'Registration failed']);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
setErrors(['Network error. Please try again.']);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// Clear errors when user starts typing
|
||||
if (errors.length > 0) {
|
||||
setErrors([]);
|
||||
}
|
||||
if (success) {
|
||||
setSuccess('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl font-bold">Create Account</CardTitle>
|
||||
<p className="text-muted-foreground">
|
||||
Join OpenGifame to share and discover amazing images
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{errors.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{errors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert className="border-green-200 bg-green-50 text-green-800">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription>{success}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter your email"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="username" className="text-sm font-medium">
|
||||
Username
|
||||
</label>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Choose a username"
|
||||
disabled={isLoading}
|
||||
maxLength={32}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No spaces, max 32 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter your password"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="confirmPassword" className="text-sm font-medium">
|
||||
Confirm Password
|
||||
</label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Confirm your password"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Creating Account...' : 'Create Account'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<Link href="/auth/signin" className="text-primary hover:underline font-medium">
|
||||
Sign in here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
src/app/auth/signin/page.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { signIn, getProviders } from 'next-auth/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Github, Mail, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface Provider {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
signinUrl: string;
|
||||
callbackUrl: string;
|
||||
}
|
||||
|
||||
export default function SignInPage() {
|
||||
const [providers, setProviders] = useState<Record<string, Provider> | null>(null);
|
||||
const [credentials, setCredentials] = useState({ email: '', password: '' });
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await getProviders();
|
||||
setProviders(res);
|
||||
})();
|
||||
|
||||
// Check for error from URL params
|
||||
const errorParam = searchParams.get('error');
|
||||
if (errorParam) {
|
||||
setError('Invalid credentials. Please try again.');
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSignIn = (providerId: string) => {
|
||||
signIn(providerId, { callbackUrl: '/' });
|
||||
};
|
||||
|
||||
const handleCredentialsSignIn = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError('Invalid credentials. Please check your email and password.');
|
||||
} else if (result?.ok) {
|
||||
router.push('/');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sign-in error:', error);
|
||||
setError('Something went wrong. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getProviderIcon = (providerId: string) => {
|
||||
switch (providerId) {
|
||||
case 'github':
|
||||
return <Github className="h-5 w-5" />;
|
||||
case 'google':
|
||||
return <Mail className="h-5 w-5" />;
|
||||
case 'facebook':
|
||||
return (
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getProviderColor = (providerId: string) => {
|
||||
switch (providerId) {
|
||||
case 'github':
|
||||
return 'bg-gray-800 hover:bg-gray-700 text-white';
|
||||
case 'google':
|
||||
return 'bg-red-600 hover:bg-red-700 text-white';
|
||||
case 'facebook':
|
||||
return 'bg-blue-600 hover:bg-blue-700 text-white';
|
||||
default:
|
||||
return 'bg-primary hover:bg-primary/90';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl font-bold">Welcome Back</CardTitle>
|
||||
<p className="text-muted-foreground">
|
||||
Sign in to your OpenGifame account
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Credentials Form */}
|
||||
<form onSubmit={handleCredentialsSignIn} className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={credentials.email}
|
||||
onChange={(e) => setCredentials(prev => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="Enter your email"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={credentials.password}
|
||||
onChange={(e) => setCredentials(prev => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="Enter your password"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OAuth Providers */}
|
||||
<div className="space-y-3">
|
||||
{providers ? (
|
||||
Object.values(providers)
|
||||
.filter(provider => provider.id !== 'credentials')
|
||||
.map((provider) => (
|
||||
<Button
|
||||
key={provider.id}
|
||||
onClick={() => handleSignIn(provider.id)}
|
||||
className={`w-full flex items-center justify-center space-x-2 ${getProviderColor(provider.id)}`}
|
||||
variant="default"
|
||||
>
|
||||
{getProviderIcon(provider.id)}
|
||||
<span>Continue with {provider.name}</span>
|
||||
</Button>
|
||||
))
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
<p className="text-center text-muted-foreground">Loading sign-in options...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Register Link */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/auth/register" className="text-primary hover:underline font-medium">
|
||||
Create one here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
82
src/app/globals.css
Normal file
@@ -0,0 +1,82 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 9% 17.9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
--radius: var(--radius);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
body {
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { useParams } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
const ImagePage = ({ params }: { params: { id: string } }) => {
|
||||
return <div>{params.id}</div>;
|
||||
};
|
||||
|
||||
export default ImagePage;
|
||||
@@ -1,74 +1,50 @@
|
||||
import { Inknut_Antiqua as font } from "next/font/google";
|
||||
import "@/styles/globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from '@/components/auth-provider';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { Header } from '@/components/header';
|
||||
|
||||
import { type Metadata, type Viewport } from "next";
|
||||
|
||||
import { TRPCReactProvider } from "@/trpc/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { TailwindIndicator } from "@/components/tailwind-indicator";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import React from "react";
|
||||
import TopNavbar from "@/components/navbar/top-navbar";
|
||||
import { dashboardConfig } from "@/config/top-nav.config";
|
||||
import { siteConfig } from "@/config/site.config";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { ClipboardListener } from "@/components/clipboard-listener";
|
||||
import {
|
||||
ClipboardContext,
|
||||
ClipboardProvider,
|
||||
} from "@/lib/clipboard/clipboard-context";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "white" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "black" },
|
||||
],
|
||||
};
|
||||
const f = font({
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
weight: ["400"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Open Gifame",
|
||||
description: siteConfig.description,
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
title: "OpenGifame - Share and Discover Images",
|
||||
description: "An open-source image sharing platform similar to Imgur",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.ico', sizes: '32x32', type: 'image/x-icon' },
|
||||
{ url: '/favicon.svg', type: 'image/svg+xml' }
|
||||
],
|
||||
apple: [
|
||||
{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }
|
||||
]
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const session = await getServerSession();
|
||||
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<title>Open Gifame</title>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
</head>
|
||||
<body
|
||||
className={cn(
|
||||
"min-h-screen bg-background font-sans antialiased",
|
||||
f.className,
|
||||
)}
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<TRPCReactProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ClipboardProvider>
|
||||
<ClipboardListener />
|
||||
<Toaster />
|
||||
<TailwindIndicator />
|
||||
|
||||
<TopNavbar items={dashboardConfig.mainNav} session={session} />
|
||||
<main className="m-4">{children}</main>
|
||||
</ClipboardProvider>
|
||||
</ThemeProvider>
|
||||
</TRPCReactProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
154
src/app/page.tsx
@@ -1,9 +1,151 @@
|
||||
import React from "react";
|
||||
import LandingPage from "@/components/pages/landing-page";
|
||||
import { TrendingPosts } from "@/components/trending-posts";
|
||||
import { getServerAuthSession } from "@/server/auth";
|
||||
import { api, HydrateClient } from "@/trpc/server";
|
||||
import { ImageCard } from '@/components/image-card';
|
||||
import { getServerAuthSession } from '@/lib/server-auth';
|
||||
|
||||
export default async function Home() {
|
||||
return <LandingPage />;
|
||||
// Temporarily disable database queries to test basic functionality
|
||||
const session = await getServerAuthSession();
|
||||
|
||||
/*
|
||||
const session = await auth();
|
||||
|
||||
// Get images with related data
|
||||
const imagesData = await db
|
||||
.select({
|
||||
id: images.id,
|
||||
title: images.title,
|
||||
description: images.description,
|
||||
url: images.url,
|
||||
upvotes: images.upvotes,
|
||||
downvotes: images.downvotes,
|
||||
createdAt: images.createdAt,
|
||||
uploadedBy: {
|
||||
name: users.name,
|
||||
image: users.image,
|
||||
},
|
||||
})
|
||||
.from(images)
|
||||
.leftJoin(users, eq(images.uploadedBy, users.id))
|
||||
.orderBy(desc(images.createdAt))
|
||||
.limit(20);
|
||||
|
||||
// Get tags for each image
|
||||
const imageIds = imagesData.map(img => img.id);
|
||||
|
||||
let imageTags_data: Array<{
|
||||
imageId: string;
|
||||
tag: { id: string; name: string } | null;
|
||||
}> = [];
|
||||
|
||||
if (imageIds.length > 0) {
|
||||
imageTags_data = await db
|
||||
.select({
|
||||
imageId: imageTags.imageId,
|
||||
tag: {
|
||||
id: tags.id,
|
||||
name: tags.name,
|
||||
},
|
||||
})
|
||||
.from(imageTags)
|
||||
.leftJoin(tags, eq(imageTags.tagId, tags.id))
|
||||
.where(inArray(imageTags.imageId, imageIds));
|
||||
}
|
||||
|
||||
// Get user votes if logged in
|
||||
let userVotes: Record<string, 'up' | 'down'> = {};
|
||||
if (session?.user?.id && imageIds.length > 0) {
|
||||
const userVotesData = await db
|
||||
.select({
|
||||
imageId: votes.imageId,
|
||||
isUpvote: votes.isUpvote,
|
||||
})
|
||||
.from(votes)
|
||||
.where(
|
||||
eq(votes.userId, session.user.id)
|
||||
);
|
||||
|
||||
const filteredVotes = userVotesData.filter(vote => imageIds.includes(vote.imageId));
|
||||
|
||||
userVotes = filteredVotes.reduce((acc: Record<string, 'up' | 'down'>, vote: { imageId: string; isUpvote: boolean }) => {
|
||||
acc[vote.imageId] = vote.isUpvote ? 'up' : 'down';
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// Get comment counts
|
||||
let commentCounts: Array<{ imageId: string; count: number }> = [];
|
||||
if (imageIds.length > 0) {
|
||||
commentCounts = await db
|
||||
.select({
|
||||
imageId: comments.imageId,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(comments)
|
||||
.where(inArray(comments.imageId, imageIds))
|
||||
.groupBy(comments.imageId);
|
||||
}
|
||||
|
||||
const commentCountMap = commentCounts.reduce((acc: Record<string, number>, { imageId, count }: { imageId: string; count: number }) => {
|
||||
acc[imageId] = count;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Group tags by image
|
||||
const tagsByImage = imageTags_data.reduce((acc: Record<string, Array<{ id: string; name: string }>>, item) => {
|
||||
if (!acc[item.imageId]) acc[item.imageId] = [];
|
||||
if (item.tag) {
|
||||
acc[item.imageId].push(item.tag);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
*/
|
||||
|
||||
// Temporary mock data for testing
|
||||
const imagesData: never[] = [];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">Latest Images</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Discover and share amazing images with the community
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Temporarily disabled while fixing auth issues */}
|
||||
{/*
|
||||
{imagesData.map((image) => (
|
||||
<ImageCard
|
||||
key={image.id}
|
||||
id={image.id}
|
||||
title={image.title}
|
||||
description={image.description || undefined}
|
||||
url={image.url}
|
||||
upvotes={image.upvotes}
|
||||
downvotes={image.downvotes}
|
||||
createdAt={image.createdAt}
|
||||
uploadedBy={{
|
||||
name: image.uploadedBy?.name || undefined,
|
||||
image: image.uploadedBy?.image || undefined,
|
||||
}}
|
||||
tags={tagsByImage[image.id] || []}
|
||||
userVote={userVotes[image.id] || null}
|
||||
commentCount={commentCountMap[image.id] || 0}
|
||||
/>
|
||||
))}
|
||||
*/}
|
||||
</div>
|
||||
|
||||
{imagesData.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
No images uploaded yet. Be the first to share!
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Database setup required - please configure your PostgreSQL database first.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
140
src/app/trending/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { db } from '@/lib/db';
|
||||
import { images, users, imageTags, tags, votes, comments } from '@/lib/db/schema';
|
||||
import { desc, sql, eq, inArray } from 'drizzle-orm';
|
||||
import { ImageCard } from '@/components/image-card';
|
||||
import { auth } from '@/lib/auth';
|
||||
|
||||
export default async function TrendingPage() {
|
||||
const session = await auth();
|
||||
|
||||
// Get trending images based on score (upvotes - downvotes) and recent activity
|
||||
const imagesData = await db
|
||||
.select({
|
||||
id: images.id,
|
||||
title: images.title,
|
||||
description: images.description,
|
||||
url: images.url,
|
||||
upvotes: images.upvotes,
|
||||
downvotes: images.downvotes,
|
||||
createdAt: images.createdAt,
|
||||
uploadedBy: {
|
||||
name: users.name,
|
||||
image: users.image,
|
||||
},
|
||||
score: sql<number>`${images.upvotes} - ${images.downvotes}`,
|
||||
})
|
||||
.from(images)
|
||||
.leftJoin(users, eq(images.uploadedBy, users.id))
|
||||
.where(sql`${images.createdAt} > NOW() - INTERVAL '30 days'`) // Only images from last 30 days
|
||||
.orderBy(desc(sql`${images.upvotes} - ${images.downvotes}`), desc(images.createdAt))
|
||||
.limit(20);
|
||||
|
||||
// Get tags for each image
|
||||
const imageIds = imagesData.map(img => img.id);
|
||||
|
||||
let imageTags_data: Array<{
|
||||
imageId: string;
|
||||
tag: { id: string; name: string } | null;
|
||||
}> = [];
|
||||
|
||||
if (imageIds.length > 0) {
|
||||
imageTags_data = await db
|
||||
.select({
|
||||
imageId: imageTags.imageId,
|
||||
tag: {
|
||||
id: tags.id,
|
||||
name: tags.name,
|
||||
},
|
||||
})
|
||||
.from(imageTags)
|
||||
.leftJoin(tags, eq(imageTags.tagId, tags.id))
|
||||
.where(inArray(imageTags.imageId, imageIds));
|
||||
}
|
||||
|
||||
// Get user votes if logged in
|
||||
let userVotes: Record<string, 'up' | 'down'> = {};
|
||||
if (session?.user?.id && imageIds.length > 0) {
|
||||
const userVotesData = await db
|
||||
.select({
|
||||
imageId: votes.imageId,
|
||||
isUpvote: votes.isUpvote,
|
||||
})
|
||||
.from(votes)
|
||||
.where(eq(votes.userId, session.user.id));
|
||||
|
||||
const filteredVotes = userVotesData.filter(vote => imageIds.includes(vote.imageId));
|
||||
|
||||
userVotes = filteredVotes.reduce((acc: Record<string, 'up' | 'down'>, vote: { imageId: string; isUpvote: boolean }) => {
|
||||
acc[vote.imageId] = vote.isUpvote ? 'up' : 'down';
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// Get comment counts
|
||||
let commentCounts: Array<{ imageId: string; count: number }> = [];
|
||||
if (imageIds.length > 0) {
|
||||
commentCounts = await db
|
||||
.select({
|
||||
imageId: comments.imageId,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(comments)
|
||||
.where(inArray(comments.imageId, imageIds))
|
||||
.groupBy(comments.imageId);
|
||||
}
|
||||
|
||||
const commentCountMap = commentCounts.reduce((acc: Record<string, number>, { imageId, count }: { imageId: string; count: number }) => {
|
||||
acc[imageId] = count;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Group tags by image
|
||||
const tagsByImage = imageTags_data.reduce((acc: Record<string, Array<{ id: string; name: string }>>, item) => {
|
||||
if (!acc[item.imageId]) acc[item.imageId] = [];
|
||||
if (item.tag) {
|
||||
acc[item.imageId].push(item.tag);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">Trending Images</h1>
|
||||
<p className="text-muted-foreground">
|
||||
The hottest images from the last 30 days based on community votes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{imagesData.map((image) => (
|
||||
<ImageCard
|
||||
key={image.id}
|
||||
id={image.id}
|
||||
title={image.title}
|
||||
description={image.description || undefined}
|
||||
url={image.url}
|
||||
upvotes={image.upvotes}
|
||||
downvotes={image.downvotes}
|
||||
createdAt={image.createdAt}
|
||||
uploadedBy={{
|
||||
name: image.uploadedBy?.name || undefined,
|
||||
image: image.uploadedBy?.image || undefined,
|
||||
}}
|
||||
tags={tagsByImage[image.id] || []}
|
||||
userVote={userVotes[image.id] || null}
|
||||
commentCount={commentCountMap[image.id] || 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{imagesData.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
No trending images found. Check back later!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
src/app/upload/page.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Upload, X } from 'lucide-react';
|
||||
|
||||
export default function UploadPage() {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card className="max-w-md mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign In Required</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>You need to sign in to upload images.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setPreview(reader.result as string);
|
||||
reader.readAsDataURL(selectedFile);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!file || !title) return;
|
||||
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('title', title);
|
||||
formData.append('description', description);
|
||||
formData.append('tags', tags);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/images/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
router.push(`/image/${result.id}`);
|
||||
} else {
|
||||
alert('Upload failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
alert('Upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Upload className="h-6 w-6" />
|
||||
<span>Upload Image</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Choose Image
|
||||
</label>
|
||||
<div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-6 text-center">
|
||||
{preview ? (
|
||||
<div className="relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
className="max-h-64 mx-auto rounded"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => {
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Upload className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
Click to select an image or drag and drop
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="mt-4"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium mb-2">
|
||||
Title *
|
||||
</label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter image title"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Enter image description (optional)"
|
||||
className="w-full p-3 border border-input rounded-md bg-background"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="tags" className="block text-sm font-medium mb-2">
|
||||
Tags
|
||||
</label>
|
||||
<Input
|
||||
id="tags"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="Enter tags separated by commas (e.g., nature, landscape, sunset)"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Separate tags with commas. New tags will be created automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!file || !title || uploading}
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload Image'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/components/auth-provider.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
|
||||
import { clipboardImageToFile } from "@/lib/clipboard/converters";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
import { ClipboardContext } from "@/lib/clipboard/clipboard-context";
|
||||
|
||||
export const ClipboardListener = () => {
|
||||
const clipboardContext = React.useContext(ClipboardContext);
|
||||
const router = useRouter();
|
||||
React.useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
logger.log("clipboard-listener", "Received clipboard data");
|
||||
if (event.clipboardData && event.clipboardData.items.length >= 0) {
|
||||
logger.log("clipboard-listener", "Received contains items");
|
||||
clipboardImageToFile(event.clipboardData, (file) => {
|
||||
clipboardContext.file = file;
|
||||
router.push("/upload?c=1");
|
||||
});
|
||||
}
|
||||
};
|
||||
logger.log("clipboard-listener", "Adding clipboard event listener");
|
||||
window.addEventListener("paste", handlePaste);
|
||||
|
||||
return () => {
|
||||
logger.log("clipboard-listener", "Removing clipboard listener");
|
||||
window.removeEventListener("paste", handlePaste);
|
||||
};
|
||||
});
|
||||
return <></>;
|
||||
};
|
||||
@@ -1,203 +0,0 @@
|
||||
import { Icons } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import React from "react";
|
||||
|
||||
type GenericErrorProps = {
|
||||
code: number;
|
||||
title: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const GenericError: React.FC<GenericErrorProps> = ({ code, title, text }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-14 flex items-center">
|
||||
<div className="container flex flex-col items-center justify-center px-5 md:flex-row">
|
||||
<div className="max-w-md">
|
||||
<div className="font-dark text-5xl font-bold">{code}</div>
|
||||
<p className="text-2xl font-light leading-normal md:text-3xl">
|
||||
{title}
|
||||
</p>
|
||||
<p className="mb-8">{text}</p>
|
||||
<Button>
|
||||
<Icons.undo className="mr-2 h-4 w-4" /> back to homepage
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-w-lg">
|
||||
<svg
|
||||
id="Layer_1"
|
||||
data-name="Layer 1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 2395 1800"
|
||||
width="400"
|
||||
>
|
||||
<defs>
|
||||
<style>{`.cls-1{fill:#d6b49a;}.cls-1,.cls-10,.cls-11,.cls-13,.cls-14,.cls-15,.cls-17,.cls-2,.cls-4,.cls-5,.cls-6{stroke:#000;}.cls-1,.cls-11,.cls-13,.cls-14,.cls-16,.cls-8{stroke-linecap:round;stroke-linejoin:round;}.cls-1,.cls-10,.cls-11,.cls-12,.cls-13,.cls-14,.cls-15,.cls-16,.cls-17,.cls-2,.cls-3,.cls-4,.cls-5,.cls-6,.cls-7,.cls-8,.cls-9{stroke-width:3px;}.cls-2{fill:#020202;}.cls-10,.cls-12,.cls-15,.cls-17,.cls-2,.cls-3,.cls-4,.cls-5,.cls-6,.cls-7,.cls-9{stroke-miterlimit:10;}.cls-3{fill:#818181;}.cls-12,.cls-16,.cls-3,.cls-7,.cls-8,.cls-9{stroke:#191818;}.cls-4{fill:#dcdbda;}.cls-5{fill:#4ea7f1;}.cls-14,.cls-6{fill:#f8f3ed;}.cls-16,.cls-7{fill:#333;}.cls-13,.cls-8{fill:none;}.cls-9{fill:#f8f59c;}.cls-10,.cls-11{fill:#f3d2c9;}.cls-15{fill:#8bb174;}.cls-17{fill:#da4e22;}`}</style>
|
||||
</defs>
|
||||
<title>Artboard 1 copy</title>
|
||||
<path
|
||||
className="cls-1"
|
||||
d="M1073.3,1016.93c-43.75-72.44-119.63-96.48-144.56-103.2h0a121.1,121.1,0,0,1-6-58.67c5.65-38.81,14.87-101.89,15.77-106.5L750,821.89,558.27,886.31c3.64,3,51.12,45.51,80.31,71.69a121.07,121.07,0,0,1,33,48.89h0c-14.84,21.13-57.72,88.19-44.92,171.84,12.09,79,67.16,129,103.83,162.39a396.42,396.42,0,0,0,88,60.44,121.54,121.54,0,0,0,98.43,19.6c5.76-1.34,16.84-4.18,27.22-7.38,4.58-1.42,10.4-3.23,17.06-5.57v0l1.1-.41,1.1-.39h0c6.61-2.47,12.24-4.8,16.67-6.65,10-4.19,20.35-9.11,25.63-11.77a121.54,121.54,0,0,0,63-78.09,396.28,396.28,0,0,0,28.85-102.77C1104.37,1159.06,1114.61,1085.35,1073.3,1016.93Z"
|
||||
/>
|
||||
<ellipse
|
||||
className="cls-2"
|
||||
cx="748.2"
|
||||
cy="816.89"
|
||||
rx="202.22"
|
||||
ry="30.98"
|
||||
transform="translate(-233.49 303.67) rotate(-19.91)"
|
||||
/>
|
||||
<path
|
||||
className="cls-3"
|
||||
d="M959,1447l-.09,82.82c0,6.19,6.66,11.22,14.88,11.23h.3c8.22,0,14.9-5,14.9-11.2l.09-81.9c0-.53-6.95-1-15.39-1H959"
|
||||
/>
|
||||
<path
|
||||
className="cls-3"
|
||||
d="M1749,1447l-.09,82.82c0,6.19,6.66,11.22,14.88,11.23h.3c8.22,0,14.9-5,14.9-11.2l.09-81.9c0-.53-7-1-15.39-1H1749"
|
||||
/>
|
||||
<path
|
||||
className="cls-4"
|
||||
d="M1825.5,1426.5H755.25a10.75,10.75,0,0,0-10.75,10.75h0A10.75,10.75,0,0,0,755.25,1448H1815a10.75,10.75,0,0,0,10.74-11l-.24-10.5"
|
||||
/>
|
||||
<path
|
||||
className="cls-5"
|
||||
d="M701.74,867.5S663.12,1015,669.56,1076.79c3.84,36.88,2.64,98,1,141.4a52.4,52.4,0,0,1-104.76-1.3c-.27-22-2.78-38.74-.5-51.2,13.67-74.81-7.27-76,5.08-144.26q3.17-11.08,6.56-22.29c11.82-39,24.77-75.25,38.5-110.61,14.74-1.39,31.2-5.77,48.93-9.73C678,875.76,690.47,871.22,701.74,867.5Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-5"
|
||||
d="M719.77,1182.37c-8.92,0-15.45-12.93-18-18-17.59-34.83,9-95.59,19.32-117.16,9.86,22.2,34.32,82.46,16.74,117.16C735.17,1169.52,728.66,1182.37,719.77,1182.37Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-6"
|
||||
d="M1915.78,1027c-110.75-95.83-248-74.53-267.79-71.13-190.52,30.41-344.62,100-368.21,188.29a549.59,549.59,0,0,0-11.7,55.33c-47.15-8-126.29-11.92-172.38,38.22l-.23.26c-13.09,14.32-3.91,37.46,15.39,39.47,11.56,1.2,25.45,2.36,41.11,3.12,32.51,1.58,102.09,52,145.66,85.51A156.16,156.16,0,0,0,1404.34,1419l.66,0c12.09,8.11,44,27.11,88.17,26.43a153,153,0,0,0,66.95-16.73l160.38-2.2c74.24,21.55,133.85,19.3,170.18,14.75,52.21-6.53,71.81-19.57,80.58-26.78,30.3-25,41.33-63.94,49.13-102.93C2036.41,1231.43,2010.61,1109.06,1915.78,1027Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-6"
|
||||
d="M1267,1198c-9.38-27.55-23.66-79.78-24.88-129.15a393.76,393.76,0,0,1,12.55-108.79,334.61,334.61,0,0,1-32.62-173.74,17.07,17.07,0,0,1,26-13l132.1,82.11a320.21,320.21,0,0,1,150.63-4.18l119.81-98a13.73,13.73,0,0,1,22.29,8.61,456.39,456.39,0,0,1-16.57,202.39,188.88,188.88,0,0,1,7.14,87.26"
|
||||
/>
|
||||
<path
|
||||
className="cls-5"
|
||||
d="M583.29,1375.5H583s-8.5-.11-16.44-7.73c-6.25-6-.85-32.43,18-63.08,16.1,31.14,20.08,57.13,14.16,63.08C591.12,1375.46,583.29,1375.5,583.29,1375.5Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-7"
|
||||
d="M2024.5,1260.5c14.81,6.82,38.24,20.41,54,46,36.42,59.15,9.28,145.76-41.37,191.33-36.76,33.08-79.09,38.28-112.39,42.57-19.52,2.51-110,13.78-172.14-42.57-12.57-11.4-42-38.11-37.66-71.13,2.25-17,13.79-39.69,33.47-46,37.71-12.14,60.28,50.17,131.09,57.83,10.2,1.1,53.88,4.58,88-23,5.59-4.52,14.81-13,26-32C2005,1364,2024.43,1323.52,2024.5,1260.5Z"
|
||||
/>
|
||||
<path className="cls-8" d="M1560.5,1428.5s69-32,85-94" />
|
||||
<path
|
||||
className="cls-7"
|
||||
d="M1530.83,851.27l119.81-98a13.73,13.73,0,0,1,22.29,8.61c3.24,22.58,4.13,45.46,4.35,81S1665,911,1656.5,964.5a284.8,284.8,0,0,0-125.67-113.23Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-8"
|
||||
d="M1408.5,1420.5c-1.77-1.54-8.83-8-9-17.67-.11-7.92,4.52-13.56,6-15.33,12.18-14.84,33.82-8.35,59-15,11.91-3.15,28.36-10.22,46-28"
|
||||
/>
|
||||
<ellipse
|
||||
className="cls-7"
|
||||
cx="1452.5"
|
||||
cy="998.5"
|
||||
rx="153"
|
||||
ry="117"
|
||||
/>
|
||||
<circle className="cls-9" cx="1355" cy="991" r="31.5" />
|
||||
<path
|
||||
className="cls-10"
|
||||
d="M1672.5,762.5s-70,95-77,117c-5.24,16.45,18.62,8.3,31,3.14a2.87,2.87,0,0,1,3.69,3.88l-8.3,17.53a6.35,6.35,0,0,0,7.75,8.74l9.91-3.3a2.87,2.87,0,0,1,3.56,3.83l-3.59,17.18,17,34a457.51,457.51,0,0,0,16-202Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-7"
|
||||
d="M1379.5,855.5c-43.86-27.19-89.35-56.1-133.21-83.29-9.07-5.62-23.66,1.62-23.79,12.29-.27,22.81-4,48.1,3,83,3.77,18.84,5.45,28.58,9.26,41.5a315.06,315.06,0,0,0,19.74,50.5,199,199,0,0,1,18-29c5.75-7.71,26.56-34.42,64-56A221.93,221.93,0,0,1,1379.5,855.5Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-11"
|
||||
d="M1222.5,782.5s75.38,65.94,84.71,83.21c.55,1,2.89,5.62,1.16,7.71-3.3,4-17.41-6.08-23.87-.92a6.77,6.77,0,0,0-1.62,1.92,8,8,0,0,0,.75,8.68c2.16,2.87,5,7.47,4.73,11.84a6.33,6.33,0,0,1-1.15,3.63c-1.93,2.36-5.52,2.38-6.51,2.38-6.55,0-10.09-6.31-10.25-6.6a4.65,4.65,0,0,0-6,.13,3.51,3.51,0,0,0-.94,2,8.85,8.85,0,0,0,.82,5.06c2.17,4.39-.37,18.55-1.85,24.93a93.65,93.65,0,0,1-11,27c-9-19.66-21.15-51-27-89a326.82,326.82,0,0,1-3.49-62.74C1221.37,793.9,1222,787.3,1222.5,782.5Z"
|
||||
/>
|
||||
<circle className="cls-12" cx="1355" cy="991" r="22.5" />
|
||||
<circle className="cls-9" cx="1557" cy="991" r="31.5" />
|
||||
<circle className="cls-12" cx="1557" cy="991" r="22.5" />
|
||||
<path
|
||||
className="cls-10"
|
||||
d="M1445.26,997.13l10.24,1.37,9.39-1.34a2.14,2.14,0,0,1,2.11,3.27l-9.09,14.28a3,3,0,0,1-4.94.08l-9.77-14.33A2.15,2.15,0,0,1,1445.26,997.13Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-13"
|
||||
d="M1454.74,1016.08s2.76,17.42-17.24,15.42"
|
||||
/>
|
||||
<path
|
||||
className="cls-13"
|
||||
d="M1455.63,1017.08s-2.76,17.42,17.24,15.42"
|
||||
/>
|
||||
<path className="cls-14" d="M1664.5,1001.5,1735,980" />
|
||||
<path className="cls-14" d="M1667,1017l66.5,10.5" />
|
||||
<path className="cls-14" d="M1244,1017l-60.5,13.5" />
|
||||
<path className="cls-14" d="M1246.5,1000.5,1180,990" />
|
||||
<path
|
||||
className="cls-15"
|
||||
d="M497.79,404c44.57,20.37,95.3,66.11,155.71,124.48,92.79,89.66,150.8,234.43,169,289-5.77,2.68-30.23-42.68-36-40-19.27-52.74-57.27-138.85-139-223-66.8-68.78-125-119.67-172-142Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-15"
|
||||
d="M745.55,850.16c-74.68-63-179.26-139.49-214.14-152.89-89.78-34.5-169.48-49.55-221.09-50.06q8.32-8.54,16.67-17.06c49-.22,119.61,13.39,199,41,31.84,11.09,153.72,90.48,241,170.65Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-15"
|
||||
d="M823.54,819.3c-17.76-23.9-59.56-97.14-83.92-120.77a597.13,597.13,0,0,0-166.5-113.78l-22.31,8.44A635.18,635.18,0,0,1,733.58,724.52c17.7,18.29,54.44,85.77,68.42,104Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-7"
|
||||
d="M1479.5,1367.5l34,76a192.85,192.85,0,0,1-51-1s-29.19-3.39-48.59-18c-13.48-10.12-14.12-17.25-14.29-19.38-.78-9.74,5.64-16.63,8.13-19l.75-.68c9-7.86,25-8.93,26-9C1444.74,1375.81,1458.89,1373.16,1479.5,1367.5Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-16"
|
||||
d="M1173.28,1285.23l30.22-89.73a156.61,156.61,0,0,0-60,11,149.83,149.83,0,0,0-38,23c-1,.85-15,12.88-15.5,24.47,0,.63,0,1.22,0,1.26.23,9.77,7.33,16,10.06,18l.82.6c8.37,5.92,18.58,5.26,33.63,5.63,8.49.21,12.73.32,18,1A113.17,113.17,0,0,1,1173.28,1285.23Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-17"
|
||||
d="M292.3,344.49l-28.05-15.3a40.34,40.34,0,0,1-20.8-39.64l2.35-22.21a61.8,61.8,0,0,1,26.57-44.52h0a29.52,29.52,0,0,1,29.48-2.22,82.16,82.16,0,0,0,8.28,3.32,234.66,234.66,0,0,1,86.78,54.37l-43.47,78.83Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-17"
|
||||
d="M318.73,318l-.69.05a40.94,40.94,0,0,0-37,32l-2.68,12.12a53.57,53.57,0,0,0,33.25,61.63L394.1,455.2,406.8,365l-57-38.69A48.91,48.91,0,0,0,318.73,318Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-17"
|
||||
d="M465,262.82l-32.13-42.59A53.66,53.66,0,0,0,379,200l-10.53,2.21A31.57,31.57,0,0,0,348.89,251l27,38.3,84.61,30.61Z"
|
||||
/>
|
||||
<circle className="cls-9" cx="395.47" cy="335.18" r="65.13" />
|
||||
<path
|
||||
className="cls-17"
|
||||
d="M410.35,262.8l-3.18,24.43c-1.27,9.71,1.05,18.92,6.5,25.82l43.66,55.28,25.6,66.79a188.3,188.3,0,0,0,13.53-28.27s9.66-27.18,8.55-57.61c-2-56.48-41.85-101.41-48.51-108.74a21.18,21.18,0,0,0-11-7c-8.32-2-15.23,2.41-18.82,4.69C414.7,245.8,411.24,258.85,410.35,262.8Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-17"
|
||||
d="M393,455.33,343.6,432.5a42.53,42.53,0,0,1-21-55.8l10.27-23.18a56,56,0,0,1,70.16-30l59.18,21.35A54.61,54.61,0,0,1,497.69,404h0a72.53,72.53,0,0,1-17.51,34.08c-22.74,24.35-55.11,23-60.87,22.72A83.93,83.93,0,0,1,393,455.33Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-17"
|
||||
d="M220.48,538.45l-4.1-14.15a39.86,39.86,0,0,1,20.26-46.64h0a44.74,44.74,0,0,1,46.87,4c12.59,4.22,69.55,24.82,98.81,84.49a161.75,161.75,0,0,1,16.25,66.83A8.26,8.26,0,0,1,386,640.17Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-17"
|
||||
d="M173.88,677.25,191,690a87.06,87.06,0,0,0,16.42,9.6,175.79,175.79,0,0,0,21.43,7.83c15.81,4.64,54.81,16.06,98.18.1,33.26-12.24,53.93-35,64.71-49.86a7,7,0,0,0-4.9-11.16L198.54,625.16a32.86,32.86,0,0,0-33,17.77A27.41,27.41,0,0,0,173.88,677.25Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-17"
|
||||
d="M160.14,576h0a63.93,63.93,0,0,0,32.92,42l57.42,29.55c3.85,1.51,9.48,3.61,16.37,5.82a265.52,265.52,0,0,0,45,10.4c27.27,3.24,57.36-5.36,74.44-11.41a13.29,13.29,0,0,0,8.07-17c-10.22-28.29-25.28-44.58-33.77-52.46-15.68-14.55-34.71-24.26-49.92-32a314.15,314.15,0,0,0-29.59-13.23l-48.9-13.51A63.9,63.9,0,0,0,184.09,530l-4.91,2.74A39.23,39.23,0,0,0,160.14,576Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-17"
|
||||
d="M525.79,497.88a10.12,10.12,0,0,0-10.16,11.81c4,23.68,14.18,75.92,28.34,89.12,18.47,17.22,48.15,116.37,130.7,95.46,56.68-14.36,39.26-73.52,22.76-109.22a117,117,0,0,0-41.89-48.75A228.19,228.19,0,0,0,597,509,260,260,0,0,0,525.79,497.88Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-15"
|
||||
d="M857.63,805C860.5,803.5,830.5,512.5,746.5,400.5s-104-130-104-130-2,85,34,145,78,160,90,182,56,223,56,223Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenericError;
|
||||
@@ -1,152 +0,0 @@
|
||||
// src/components/RegistrationForm.tsx
|
||||
import React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/trpc/react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const registrationSchema = z
|
||||
.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
password: z
|
||||
.string()
|
||||
.min(5, { message: "Password must be at least 5 characters long" }),
|
||||
confirmPassword: z.string().min(5),
|
||||
})
|
||||
.superRefine(({ confirmPassword, password }, ctx) => {
|
||||
if (confirmPassword !== password) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "The passwords did not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type RegistrationFormValues = z.infer<typeof registrationSchema>;
|
||||
|
||||
const RegistrationForm: React.FC = () => {
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const router = useRouter();
|
||||
const form = useForm<RegistrationFormValues>({
|
||||
resolver: zodResolver(registrationSchema),
|
||||
});
|
||||
|
||||
const createUser = api.auth.create.useMutation();
|
||||
|
||||
const onSubmit = async (data: RegistrationFormValues) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await createUser.mutateAsync(data);
|
||||
toast("User registered successfully");
|
||||
router.push("/signin");
|
||||
} catch (error) {
|
||||
logger.error("RegistrationForm", "error", error);
|
||||
toast("Failed to register user");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
defaultValue={"fergal.moran+opengifame@gmail.com"}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
{form.formState.errors.email && (
|
||||
<FormMessage>
|
||||
{form.formState.errors.email.message}
|
||||
</FormMessage>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
defaultValue={"secret"}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
{form.formState.errors.password && (
|
||||
<FormMessage>
|
||||
{form.formState.errors.password.message}
|
||||
</FormMessage>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
defaultValue={"secret"}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
{form.formState.errors.confirmPassword && (
|
||||
<FormMessage>
|
||||
{form.formState.errors.confirmPassword.message}
|
||||
</FormMessage>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors && false && (
|
||||
<Alert>
|
||||
<Icons.terminal className="h-4 w-4" />
|
||||
<AlertTitle>Heads up!</AlertTitle>
|
||||
<AlertDescription>
|
||||
{JSON.stringify(form.formState.errors)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className={cn(buttonVariants())}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && (
|
||||
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Register
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegistrationForm;
|
||||
@@ -1,129 +0,0 @@
|
||||
// src/components/RegistrationForm.tsx
|
||||
import React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/trpc/react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "next-auth/react";
|
||||
|
||||
const signInSchema = z.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
password: z
|
||||
.string()
|
||||
.min(5, { message: "Password must be at least 5 characters long" }),
|
||||
});
|
||||
|
||||
type SignInFormValues = z.infer<typeof signInSchema>;
|
||||
|
||||
const SignInForm: React.FC = () => {
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const router = useRouter();
|
||||
const form = useForm<SignInFormValues>({
|
||||
resolver: zodResolver(signInSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: SignInFormValues) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
redirect: false,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
});
|
||||
logger.debug("signin", "result", result);
|
||||
if (result?.status === 200) {
|
||||
router.push("/");
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("SignInForm", "error", error);
|
||||
toast("Failed to signin user");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
defaultValue={"fergal.moran+opengifame@gmail.com"}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
{form.formState.errors.email && (
|
||||
<FormMessage>
|
||||
{form.formState.errors.email.message}
|
||||
</FormMessage>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
defaultValue={"secret"}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
{form.formState.errors.password && (
|
||||
<FormMessage>
|
||||
{form.formState.errors.password.message}
|
||||
</FormMessage>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors && false && (
|
||||
<Alert>
|
||||
<Icons.terminal className="h-4 w-4" />
|
||||
<AlertTitle>Heads up!</AlertTitle>
|
||||
<AlertDescription>
|
||||
{JSON.stringify(form.formState.errors)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className={cn(buttonVariants())}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && (
|
||||
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Sign in
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignInForm;
|
||||
275
src/components/header.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
'use client';
|
||||
|
||||
import { signIn, signOut, useSession, getProviders } from 'next-auth/react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Upload, User, LogOut, Github, Mail, AlertCircle } from 'lucide-react';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { OpenGifameLogo } from '@/components/opengifame-logo';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Provider {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
signinUrl: string;
|
||||
callbackUrl: string;
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const { data: session } = useSession();
|
||||
const [providers, setProviders] = useState<Record<string, Provider> | null>(null);
|
||||
const [isSignInOpen, setIsSignInOpen] = useState(false);
|
||||
const [credentials, setCredentials] = useState({ email: '', password: '' });
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await getProviders();
|
||||
setProviders(res);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Reset form state when modal closes
|
||||
useEffect(() => {
|
||||
if (!isSignInOpen) {
|
||||
setCredentials({ email: '', password: '' });
|
||||
setError('');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isSignInOpen]);
|
||||
|
||||
const handleSignIn = (providerId: string) => {
|
||||
signIn(providerId, { callbackUrl: '/' });
|
||||
setIsSignInOpen(false);
|
||||
};
|
||||
|
||||
const handleCredentialsSignIn = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError('Invalid credentials. Please check your email and password.');
|
||||
} else if (result?.ok) {
|
||||
setIsSignInOpen(false);
|
||||
setCredentials({ email: '', password: '' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sign-in error:', error);
|
||||
setError('Something went wrong. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getProviderIcon = (providerId: string) => {
|
||||
switch (providerId) {
|
||||
case 'github':
|
||||
return <Github className="h-4 w-4" />;
|
||||
case 'google':
|
||||
return <Mail className="h-4 w-4" />;
|
||||
case 'facebook':
|
||||
return (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getProviderColor = (providerId: string) => {
|
||||
switch (providerId) {
|
||||
case 'github':
|
||||
return 'bg-gray-800 hover:bg-gray-700 text-white';
|
||||
case 'google':
|
||||
return 'bg-red-600 hover:bg-red-700 text-white';
|
||||
case 'facebook':
|
||||
return 'bg-blue-600 hover:bg-blue-700 text-white';
|
||||
default:
|
||||
return 'bg-primary hover:bg-primary/90';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background">
|
||||
<div className="container mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
|
||||
<div className="flex items-center space-x-6 flex-shrink-0">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<OpenGifameLogo className="h-8 w-8 flex-shrink-0" />
|
||||
<span className="text-xl font-bold">OpenGifame</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden sm:flex items-center space-x-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm font-medium transition-colors hover:text-primary"
|
||||
>
|
||||
Gallery
|
||||
</Link>
|
||||
<Link
|
||||
href="/trending"
|
||||
className="text-sm font-medium transition-colors hover:text-primary"
|
||||
>
|
||||
Trending
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 flex-shrink-0">
|
||||
<ThemeToggle />
|
||||
{session ? (
|
||||
<>
|
||||
<Button asChild size="sm">
|
||||
<Link href="/upload">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Upload
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/profile">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
{session.user?.name}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => signOut()}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Dialog open={isSignInOpen} onOpenChange={setIsSignInOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
Sign In
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">Welcome to OpenGifame</DialogTitle>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Sign in to share and discover amazing images
|
||||
</p>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6 pt-4">
|
||||
{/* Credentials Form */}
|
||||
<form onSubmit={handleCredentialsSignIn} className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="modal-email" className="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="modal-email"
|
||||
type="email"
|
||||
value={credentials.email}
|
||||
onChange={(e) => setCredentials(prev => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="Enter your email"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="modal-password" className="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
id="modal-password"
|
||||
type="password"
|
||||
value={credentials.password}
|
||||
onChange={(e) => setCredentials(prev => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="Enter your password"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OAuth Providers */}
|
||||
<div className="space-y-3">
|
||||
{providers ? (
|
||||
Object.values(providers)
|
||||
.filter(provider => provider.id !== 'credentials')
|
||||
.map((provider) => (
|
||||
<Button
|
||||
key={provider.id}
|
||||
onClick={() => handleSignIn(provider.id)}
|
||||
className={`w-full flex items-center justify-center space-x-2 ${getProviderColor(provider.id)}`}
|
||||
variant="default"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{getProviderIcon(provider.id)}
|
||||
<span>Continue with {provider.name}</span>
|
||||
</Button>
|
||||
))
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
<p className="text-center text-muted-foreground">Loading sign-in options...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Register Link */}
|
||||
<div className="text-center pt-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="text-primary hover:underline font-medium"
|
||||
onClick={() => setIsSignInOpen(false)}
|
||||
>
|
||||
Create one here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Command,
|
||||
CreditCard,
|
||||
File,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
Image,
|
||||
Laptop,
|
||||
Loader2,
|
||||
type LucideProps,
|
||||
Moon,
|
||||
MoreVertical,
|
||||
Pizza,
|
||||
Plus,
|
||||
Settings,
|
||||
SunMedium,
|
||||
Trash,
|
||||
Twitter,
|
||||
User,
|
||||
X,
|
||||
type Icon as LucideIcon,
|
||||
Terminal,
|
||||
LogIn,
|
||||
Upload,
|
||||
Menu,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
Heart,
|
||||
Undo2,
|
||||
LoaderCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
export type Icon = typeof LucideIcon;
|
||||
|
||||
export const Icons = {
|
||||
close: X,
|
||||
spinner: Loader2,
|
||||
chevronLeft: ChevronLeft,
|
||||
chevronRight: ChevronRight,
|
||||
trash: Trash,
|
||||
heart: Heart,
|
||||
up: ThumbsUp,
|
||||
down: ThumbsDown,
|
||||
post: FileText,
|
||||
page: File,
|
||||
progress: LoaderCircle,
|
||||
media: Image,
|
||||
settings: Settings,
|
||||
billing: CreditCard,
|
||||
ellipsis: MoreVertical,
|
||||
add: Plus,
|
||||
warning: AlertTriangle,
|
||||
user: User,
|
||||
undo: Undo2,
|
||||
arrowRight: ArrowRight,
|
||||
help: HelpCircle,
|
||||
login: LogIn,
|
||||
logo: ({ ...props }: LucideProps) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" {...props}>
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="100"
|
||||
height="100"
|
||||
rx="20"
|
||||
ry="20"
|
||||
fill="#FF6B6B"
|
||||
/>
|
||||
<path d="M50,10 L90,80 L10,80 Z" fill="white" />
|
||||
<path
|
||||
d="M50,10 L62,80"
|
||||
stroke="#FFD166"
|
||||
strokeWidth={4}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M50,10 L50,80"
|
||||
stroke="#06D6A0"
|
||||
strokeWidth={4}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M50,10 L38,80"
|
||||
stroke="#118AB2"
|
||||
strokeWidth={4}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="35"
|
||||
r="9"
|
||||
fill="#FF6B6B"
|
||||
stroke="white"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
pizza: Pizza,
|
||||
terminal: Terminal,
|
||||
sun: SunMedium,
|
||||
moon: Moon,
|
||||
laptop: Laptop,
|
||||
gitHub: ({ ...props }: LucideProps) => (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
data-prefix="fab"
|
||||
data-icon="github"
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 496 512"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
hamburger: Menu,
|
||||
twitter: Twitter,
|
||||
check: Check,
|
||||
upload: Upload,
|
||||
};
|
||||
159
src/components/image-card.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowUp, ArrowDown, MessageCircle, Calendar } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface ImageCardProps {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
createdAt: Date;
|
||||
uploadedBy: {
|
||||
name?: string;
|
||||
image?: string;
|
||||
};
|
||||
tags: {
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
userVote?: 'up' | 'down' | null;
|
||||
commentCount: number;
|
||||
}
|
||||
|
||||
export function ImageCard({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
upvotes,
|
||||
downvotes,
|
||||
createdAt,
|
||||
uploadedBy,
|
||||
tags,
|
||||
userVote,
|
||||
commentCount,
|
||||
}: ImageCardProps) {
|
||||
const { data: session } = useSession();
|
||||
const [currentVote, setCurrentVote] = useState(userVote);
|
||||
const [voteCount, setVoteCount] = useState({ upvotes, downvotes });
|
||||
|
||||
const handleVote = async (voteType: 'up' | 'down') => {
|
||||
if (!session) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/images/vote', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageId: id, isUpvote: voteType === 'up' }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCurrentVote(data.userVote);
|
||||
setVoteCount({ upvotes: data.upvotes, downvotes: data.downvotes });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error voting:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const score = voteCount.upvotes - voteCount.downvotes;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href={`/image/${id}`}
|
||||
className="text-lg font-semibold hover:text-primary"
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{score > 0 ? `+${score}` : score}
|
||||
</span>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
<Link href={`/image/${id}`}>
|
||||
<div className="relative aspect-video w-full overflow-hidden">
|
||||
<Image
|
||||
src={url}
|
||||
alt={title}
|
||||
fill
|
||||
className="object-cover transition-transform hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-col space-y-2 pt-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant={currentVote === 'up' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleVote('up')}
|
||||
disabled={!session}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
{voteCount.upvotes}
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentVote === 'down' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleVote('down')}
|
||||
disabled={!session}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
{voteCount.downvotes}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/image/${id}`}>
|
||||
<MessageCircle className="mr-1 h-4 w-4" />
|
||||
{commentCount}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{createdAt.toLocaleDateString()}</span>
|
||||
</div>
|
||||
<span>by {uploadedBy.name}</span>
|
||||
</div>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<Link
|
||||
key={tag.id}
|
||||
href={`/tag/${tag.name}`}
|
||||
className="rounded-full bg-secondary px-2 py-1 text-xs hover:bg-secondary/80"
|
||||
>
|
||||
#{tag.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { NavItem } from "@/types";
|
||||
import { siteConfig } from "@/config/site.config";
|
||||
|
||||
interface MobileNavProps {
|
||||
items: NavItem[];
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MobileNav({ items, children }: MobileNavProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 top-16 z-50 grid h-[calc(100vh-4rem)] grid-flow-row auto-rows-max overflow-auto p-6 pb-32 shadow-md animate-in slide-in-from-bottom-80 md:hidden",
|
||||
)}
|
||||
>
|
||||
<div className="relative z-20 grid gap-6 rounded-md bg-popover p-4 text-popover-foreground shadow-md">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<Icons.logo />
|
||||
<span className="font-bold">{siteConfig.name}</span>
|
||||
</Link>
|
||||
<nav className="grid grid-flow-row auto-rows-max text-sm">
|
||||
{items.map((item, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={item.disabled ? "#" : item.href}
|
||||
className={cn(
|
||||
"flex w-full items-center rounded-md p-2 text-sm font-medium hover:underline",
|
||||
item.disabled && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
import { type NavItem } from "@/types";
|
||||
import { type Session } from "next-auth";
|
||||
|
||||
import { Icons } from "@/components/icons";
|
||||
import { siteConfig } from "@/config/site.config";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSelectedLayoutSegment } from "next/navigation";
|
||||
|
||||
import LoginButton from "@/components/widgets/login/login-button";
|
||||
import { MobileNav } from "./mobile-navbar";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
type TopNavbarProps = {
|
||||
items: NavItem[];
|
||||
session: Session | null;
|
||||
};
|
||||
|
||||
const TopNavbar: React.FC<TopNavbarProps> = ({ items, session }) => {
|
||||
const segment = useSelectedLayoutSegment();
|
||||
const [showMobileMenu, setShowMobileMenu] = React.useState<boolean>(false);
|
||||
return (
|
||||
<div className="mx-auto px-2 sm:px-4 lg:divide-y lg:divide-gray-200 lg:px-8">
|
||||
<div className="relative flex h-16 justify-between">
|
||||
<div className="relative z-10 flex px-2 lg:px-0">
|
||||
<div className="flex flex-shrink-0 items-center gap-4">
|
||||
<Link href="/" className="hidden items-center space-x-2 md:flex">
|
||||
<Icons.logo className="h-8 w-8" />
|
||||
<span className="hidden font-bold sm:inline-block">
|
||||
{siteConfig.name}
|
||||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
className={buttonVariants({ variant: "secondary" })}
|
||||
href={"/upload"}
|
||||
>
|
||||
<Icons.upload className="mr-2 h-4 w-4" />
|
||||
Upload
|
||||
</Link>
|
||||
{items?.length ? (
|
||||
<nav className="hidden gap-6 md:flex">
|
||||
{items?.map((item, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={item.disabled ? "#" : item.href}
|
||||
className={cn(
|
||||
"flex items-center text-lg font-medium transition-colors hover:text-foreground/80 sm:text-sm",
|
||||
item.href.startsWith(`/${segment}`)
|
||||
? "text-foreground"
|
||||
: "text-foreground/60",
|
||||
item.disabled && "cursor-not-allowed opacity-80",
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-0 flex flex-1 items-center justify-center px-2 sm:absolute sm:inset-0">
|
||||
<div className="w-full sm:max-w-xs">
|
||||
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
||||
<div className="w-full flex-1 md:w-auto md:flex-none">
|
||||
<button className="relative inline-flex h-8 w-full items-center justify-start whitespace-nowrap rounded-[0.5rem] border border-input bg-muted/50 px-4 py-2 text-sm font-normal text-muted-foreground shadow-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 sm:pr-12 md:w-40 lg:w-64">
|
||||
<span className="hidden lg:inline-flex">
|
||||
Search images...
|
||||
</span>
|
||||
<span className="inline-flex lg:hidden">Search...</span>
|
||||
<kbd className="pointer-events-none absolute right-[0.3rem] top-[0.3rem] hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
|
||||
<span className="text-xs">⌘</span>K
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10 flex items-center md:hidden">
|
||||
<button
|
||||
className="flex items-center space-x-2 md:hidden"
|
||||
onClick={() => setShowMobileMenu(!showMobileMenu)}
|
||||
>
|
||||
{showMobileMenu ? (
|
||||
<Icons.close className="w-8" />
|
||||
) : (
|
||||
<Icons.logo className="w-8" />
|
||||
)}
|
||||
</button>
|
||||
{showMobileMenu && items && <MobileNav items={items} />}
|
||||
</div>
|
||||
<div className="hidden md:relative md:z-10 md:ml-4 md:flex md:items-center">
|
||||
<LoginButton session={session} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopNavbar;
|
||||
93
src/components/opengifame-logo.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
export function OpenGifameLogo({ className = "h-8 w-8" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 32 32"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx="16"
|
||||
cy="16"
|
||||
r="15"
|
||||
fill="#3B82F6"
|
||||
stroke="#2563EB"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Camera/Gallery icon */}
|
||||
<g transform="translate(6, 8)">
|
||||
{/* Camera body */}
|
||||
<rect
|
||||
x="2"
|
||||
y="4"
|
||||
width="16"
|
||||
height="12"
|
||||
rx="2"
|
||||
fill="white"
|
||||
opacity="0.95"
|
||||
/>
|
||||
|
||||
{/* Camera lens outer */}
|
||||
<circle
|
||||
cx="10"
|
||||
cy="10"
|
||||
r="3.5"
|
||||
fill="none"
|
||||
stroke="#3B82F6"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
|
||||
{/* Camera lens inner */}
|
||||
<circle
|
||||
cx="10"
|
||||
cy="10"
|
||||
r="2"
|
||||
fill="#3B82F6"
|
||||
/>
|
||||
|
||||
{/* Camera viewfinder */}
|
||||
<rect
|
||||
x="4"
|
||||
y="2"
|
||||
width="4"
|
||||
height="2"
|
||||
rx="1"
|
||||
fill="white"
|
||||
opacity="0.95"
|
||||
/>
|
||||
|
||||
{/* Gallery indicator (small rectangles) */}
|
||||
<rect
|
||||
x="14.5"
|
||||
y="5.5"
|
||||
width="2"
|
||||
height="1.5"
|
||||
rx="0.3"
|
||||
fill="#3B82F6"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<rect
|
||||
x="14.5"
|
||||
y="7.5"
|
||||
width="2"
|
||||
height="1.5"
|
||||
rx="0.3"
|
||||
fill="#3B82F6"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<rect
|
||||
x="14.5"
|
||||
y="9.5"
|
||||
width="2"
|
||||
height="1.5"
|
||||
rx="0.3"
|
||||
fill="#3B82F6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from "react";
|
||||
import { TrendingPosts } from "@/components/trending-posts";
|
||||
import { api, HydrateClient } from "@/trpc/server";
|
||||
|
||||
const LandingPage: React.FC = async () => {
|
||||
void api.post.getTrending.prefetch();
|
||||
|
||||
return (
|
||||
<HydrateClient>
|
||||
<TrendingPosts />
|
||||
</HydrateClient>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Post } from "@/lib/models/post";
|
||||
import React from "react";
|
||||
import ActionButton from "@/components/widgets/action-button";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { api } from "@/trpc/react";
|
||||
import VoteCount from "@/components/widgets/vote-count";
|
||||
|
||||
type PostActionsProps = {
|
||||
post: Post;
|
||||
};
|
||||
|
||||
const PostActions: React.FC<PostActionsProps> = ({ post }) => {
|
||||
const vote = api.post.vote.useMutation();
|
||||
const voteCount = api.post.getVoteCount.useQuery({ slug: post.slug });
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4 rounded-md border p-4">
|
||||
<ActionButton
|
||||
title="Upvote"
|
||||
action={async () => {
|
||||
await vote.mutateAsync({ slug: post.slug, up: true });
|
||||
await voteCount.refetch();
|
||||
}}
|
||||
icon={<Icons.up className="h-6 w-6" />}
|
||||
/>
|
||||
<VoteCount post={post} />
|
||||
<ActionButton
|
||||
title="Downvote"
|
||||
action={async () => {
|
||||
await vote.mutateAsync({ slug: post.slug, up: false });
|
||||
await voteCount.refetch();
|
||||
}}
|
||||
icon={<Icons.down className="h-6 w-6" />}
|
||||
/>
|
||||
<ActionButton
|
||||
title="Favourite"
|
||||
action={async () => {
|
||||
await vote.mutateAsync({ slug: post.slug, up: false });
|
||||
await voteCount.refetch();
|
||||
}}
|
||||
icon={<Icons.heart className="h-6 w-6" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostActions;
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { Post } from "@/lib/models/post";
|
||||
import ActionButton from "@/components/widgets/action-button";
|
||||
import { Icons } from "@/components/icons";
|
||||
import PostActions from "./post-actions";
|
||||
|
||||
type PostPageProps = {
|
||||
post: Post;
|
||||
};
|
||||
|
||||
const PostPage: React.FC<PostPageProps> = ({ post }) => {
|
||||
return (
|
||||
<div className="flex h-full w-full items-stretch space-x-4">
|
||||
<div id="left" className="w-1/6 flex-none">
|
||||
<div className="mx-10">
|
||||
<PostActions post={post} />
|
||||
</div>
|
||||
</div>
|
||||
<div id="centre" className="flex-grow">
|
||||
<h2 className="mb-2 text-xl font-bold">{post.title}</h2>
|
||||
<div className="rounded-t-md border-l border-r border-t bg-card text-card-foreground shadow">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-center p-4">
|
||||
<img
|
||||
src={post.imageUrl}
|
||||
className="h-auto max-w-full rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-b-md border-b border-l border-r bg-muted">
|
||||
<p className="p-4 text-sm opacity-75">{post.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="right" className="w-1/6 flex-none">
|
||||
Right Stuff
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostPage;
|
||||
@@ -1,169 +0,0 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { type SubmitHandler, Controller } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ImageUpload from "@/components/widgets/image-upload";
|
||||
import TaggedInput from "@/components/widgets/tagged-input";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { api } from "@/trpc/react";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
|
||||
type FormValues = {
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
image: string | undefined;
|
||||
};
|
||||
|
||||
type UploadPageProps = {
|
||||
pastedImage?: File;
|
||||
};
|
||||
const UploadPage: React.FC<UploadPageProps> = ({ pastedImage }) => {
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
title: env.NEXT_PUBLIC_DEBUG_MODE ? "This is my title" : "",
|
||||
description: env.NEXT_PUBLIC_DEBUG_MODE ? "This is my description" : "",
|
||||
tags: [],
|
||||
},
|
||||
});
|
||||
if (pastedImage) {
|
||||
logger.log("upload-page", "We gotta file", pastedImage);
|
||||
} else {
|
||||
logger.log("upload-page", "No file");
|
||||
}
|
||||
|
||||
const createImage = api.post.create.useMutation({
|
||||
onSuccess: async (e) => {
|
||||
console.log("upload-page", "onSuccess", e);
|
||||
const file = form.getValues().image;
|
||||
if (e.id && file) {
|
||||
const body = new FormData();
|
||||
body.set("image", file);
|
||||
const response = await fetch(`/api/upload/post?id=${e.id}`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
if (response.status === StatusCodes.OK.valueOf()) {
|
||||
await utils.post.getBySlug.invalidate();
|
||||
await utils.post.getTrending.invalidate();
|
||||
router.replace(`/post/${e.slug}`);
|
||||
}
|
||||
logger.error("upload-page", "createImage", response.statusText);
|
||||
throw new Error(response.statusText);
|
||||
} else {
|
||||
//TODO: Probably need to delete the image from the database
|
||||
logger.error("upload-page", "onSuccess", "Error uploading image");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const _submit: SubmitHandler<FormValues> = async (data) => {
|
||||
console.log(data);
|
||||
try {
|
||||
await createImage.mutateAsync(data);
|
||||
} catch (error) {
|
||||
logger.error("UploadPage", "error", error);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="md:grid md:grid-cols-3 md:gap-6">
|
||||
<div className="md:col-span-1">
|
||||
<div className="px-4 sm:px-0">
|
||||
<h3 className="text-lg font-extrabold leading-6">Upload a new gif</h3>
|
||||
<p className="text-base-content/70 my-3 text-sm">
|
||||
The more info you can give us the better.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(_submit)}>
|
||||
<div className="space-y-4 px-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Let's give your post a title"
|
||||
type="text"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{form.formState.errors.title && (
|
||||
<FormMessage>
|
||||
{form.formState.errors.title.message}
|
||||
</FormMessage>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="image"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ImageUpload
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
pastedImage={pastedImage}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
defaultValue={"fergal.moran+opengifame@gmail.com"}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Add a description (if you want?)."
|
||||
{...field}
|
||||
rows={3}
|
||||
/>
|
||||
</FormControl>
|
||||
{form.formState.errors.description && (
|
||||
<FormMessage>
|
||||
{form.formState.errors.description.message}
|
||||
</FormMessage>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="divider pt-4">optional stuff</div>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="tags"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TaggedInput label="Tags" value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full px-4 py-3 text-right">
|
||||
<Button type="submit" className="btn btn-primary w-full">
|
||||
Upload Gif
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadPage;
|
||||
@@ -1,16 +0,0 @@
|
||||
export function TailwindIndicator() {
|
||||
if (process.env.NODE_ENV === "production") return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-1 left-1 z-50 flex h-6 w-6 items-center justify-center rounded-full bg-gray-800 p-3 font-mono text-xs text-white">
|
||||
<div className="block sm:hidden">xs</div>
|
||||
<div className="hidden sm:block md:hidden lg:hidden xl:hidden 2xl:hidden">
|
||||
sm
|
||||
</div>
|
||||
<div className="hidden md:block lg:hidden xl:hidden 2xl:hidden">md</div>
|
||||
<div className="hidden lg:block xl:hidden 2xl:hidden">lg</div>
|
||||
<div className="hidden xl:block 2xl:hidden">xl</div>
|
||||
<div className="hidden 2xl:block">2xl</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import * as React from 'react';
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
40
src/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<Button variant="ghost" size="sm" className="h-9 w-9 p-0">
|
||||
<Sun className="h-4 w-4" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
||||
className="h-9 w-9 p-0"
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<Moon className="h-4 w-4" />
|
||||
) : (
|
||||
<Sun className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { api } from "@/trpc/server";
|
||||
import Link from "next/link";
|
||||
import { Icons } from "./icons";
|
||||
import { type Post } from "@/lib/models/post";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "./ui/button";
|
||||
import VoteCount from "./widgets/vote-count";
|
||||
import Loading from "@/components/widgets/loading";
|
||||
|
||||
export const TrendingPosts: React.FC = async () => {
|
||||
const trendingPosts = await api.post.getTrending();
|
||||
return trendingPosts.length !== 0 ? (
|
||||
<div className="masonry sm:masonry-sm md:masonry-md lg:masonry-lg">
|
||||
{trendingPosts.map((post: Post) => (
|
||||
<div className="py-2" key={post.slug}>
|
||||
<Link href={`/post/${post.slug}`}>
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="p-0">
|
||||
<img
|
||||
src={post.imageUrl}
|
||||
alt={post.title}
|
||||
className="h-auto w-full object-cover"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<CardTitle className="text-sm">{post.title}</CardTitle>
|
||||
<p className="mt-2 line-clamp-1 text-xs text-muted-foreground lg:line-clamp-2">
|
||||
{post.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between px-1 text-xs">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button variant="ghost" size="sm">
|
||||
<Icons.up className="h-2 w-2" />
|
||||
</Button>
|
||||
<VoteCount post={post} />
|
||||
<Button variant="ghost" size="sm">
|
||||
<Icons.down className="h-2 w-2" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="font-semibold"></span>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Loading />
|
||||
);
|
||||
};
|
||||
@@ -4,11 +4,11 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
default: "bg-background text-foreground border-border",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
@@ -19,41 +19,48 @@ const alertVariants = cva(
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center 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",
|
||||
"inline-flex items-center justify-center 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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -21,11 +21,7 @@ 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}
|
||||
/>
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
|
||||
120
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
"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,
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,24 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ 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-sm 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",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,129 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
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}
|
||||
>
|
||||
<Cross2Icon 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,
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type ActionButtonProps = {
|
||||
title: string;
|
||||
action: React.MouseEventHandler<HTMLDivElement>;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
const ActionButton: React.FC<ActionButtonProps> = ({ title, action, icon }) => {
|
||||
return (
|
||||
<div onClick={action} className="cursor-pointer hover:text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionButton;
|
||||
@@ -1,69 +0,0 @@
|
||||
'use client';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import React from 'react';
|
||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||
import { HiOutlineClipboardCopy } from 'react-icons/hi';
|
||||
|
||||
interface ICopyTextInput {
|
||||
label: string;
|
||||
text: string;
|
||||
}
|
||||
const CopyTextInput: React.FC<ICopyTextInput> = ({ label, text }) => {
|
||||
const [showResult, setShowResult] = React.useState(false);
|
||||
const _onCopy = () => {
|
||||
setShowResult(true);
|
||||
setTimeout(() => {
|
||||
setShowResult(false);
|
||||
}, 2000);
|
||||
};
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="text-to-copy"
|
||||
className="block text-sm font-medium label"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
|
||||
<div className="flex mt-1 rounded-md shadow-sm ">
|
||||
<div className="relative flex items-stretch flex-grow indicator focus-within:z-10">
|
||||
<Transition
|
||||
show={showResult}
|
||||
enter="transition-opacity duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-500"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<span className="w-3/4 indicator-item indicator-center badge badge-accent">
|
||||
Copied successfully
|
||||
</span>
|
||||
</Transition>
|
||||
<input
|
||||
id="text-to-copy"
|
||||
readOnly={true}
|
||||
value={text}
|
||||
className="block w-full rounded-none input input-md rounded-l-md sm:text-sm"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={text}
|
||||
onCopy={_onCopy}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="relative inline-flex items-center px-4 py-2 -ml-px space-x-2 text-sm font-medium btn rounded-r-md"
|
||||
>
|
||||
<HiOutlineClipboardCopy className="w-6 h-6 text-accent" />
|
||||
</button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyTextInput;
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import { TbThumbUp, TbThumbDown } from "react-icons/tb";
|
||||
import Link from "next/link";
|
||||
|
||||
// interface IGifContainerProps {
|
||||
// gif: Gif;
|
||||
// isLink?: boolean;
|
||||
// showDetails?: boolean;
|
||||
// }
|
||||
// const GifContainer: React.FC<IGifContainerProps> = ({
|
||||
// gif,
|
||||
// isLink = true,
|
||||
// showDetails = true,
|
||||
// }) => {
|
||||
// const [upVotes, setUpVotes] = React.useState<number>(gif.upVotes);
|
||||
// const [downVotes, setDownVotes] = React.useState<number>(gif.downVotes);
|
||||
|
||||
// const _doot = async (id: string, isUp: boolean) => {
|
||||
// const response = await fetch(`api/votes?gifId=${id}&isUp=${isUp ? 1 : 0}`, {
|
||||
// method: 'POST',
|
||||
// });
|
||||
// if (response.status === 200) {
|
||||
// const result = (await response.json()) as Gif;
|
||||
// setUpVotes(result.upVotes);
|
||||
// setDownVotes(result.downVotes);
|
||||
// }
|
||||
// };
|
||||
// return (
|
||||
// <>
|
||||
// <div className="group relative h-[17.5rem] transform overflow-hidden rounded-4xl">
|
||||
// <div className="absolute inset-0">
|
||||
// {isLink ? (
|
||||
// <Link
|
||||
// href={`gifs/${gif.slug}`}
|
||||
// title={gif.title}
|
||||
// >
|
||||
// <Image
|
||||
// alt={gif.title}
|
||||
// className="absolute inset-0 transition duration-300 group-hover:scale-110"
|
||||
// src={gif.fileName}
|
||||
// fill
|
||||
// sizes="100vw"
|
||||
// style={{
|
||||
// objectFit: 'fill',
|
||||
// }}
|
||||
// />
|
||||
// </Link>
|
||||
// ) : (
|
||||
// <Image
|
||||
// alt={gif.title}
|
||||
// className="absolute inset-0 transition duration-300 group-hover:scale-110"
|
||||
// src={gif.fileName}
|
||||
// fill
|
||||
// sizes="100vw"
|
||||
// style={{
|
||||
// objectFit: 'fill',
|
||||
// }}
|
||||
// />
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// {showDetails && (
|
||||
// <div className="flex flex-row p-2">
|
||||
// <div className="flex-1 space-x-2 text-base">
|
||||
// {gif.searchTerms?.map((t) => (
|
||||
// <div
|
||||
// key={t}
|
||||
// className="mr-0.5 badge badge-info badge-md badge-outline"
|
||||
// >
|
||||
// {`#${t}`}
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
// <div className="flex items-center justify-center space-x-1">
|
||||
// <div className="flex transition duration-75 ease-in-out delay-150 hover:text-orange-700 hover:cursor-pointer">
|
||||
// <span onClick={() => _doot(gif.id, true)}>
|
||||
// <TbThumbUp className="w-5" />
|
||||
// </span>
|
||||
// <span className="text-xs">{upVotes}</span>
|
||||
// </div>
|
||||
// <div className="flex transition duration-75 ease-in-out delay-150 hover:text-orange-700 hover:cursor-pointer">
|
||||
// <span
|
||||
// onClick={() => _doot(gif.id, false)}
|
||||
// className="pl-2 "
|
||||
// >
|
||||
// <TbThumbDown className="w-5" />
|
||||
// </span>
|
||||
// <span className="text-xs">{downVotes}</span>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// )}
|
||||
// </>
|
||||
// );
|
||||
// };
|
||||
|
||||
// export default GifContainer;
|
||||