├── .env.example ├── .eslintignore ├── .eslintrc.cjs ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── dashboard.png ├── package-lock.json ├── package.json ├── postcss.config.js ├── prisma ├── migrations │ ├── 20230806180918_init │ │ └── migration.sql │ ├── 20230806184925_image │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.ts ├── src ├── app.css ├── app.d.ts ├── app.html ├── hooks.server.ts ├── lib │ ├── components │ │ ├── AppShell.svelte │ │ ├── AuthForm.svelte │ │ ├── Header.svelte │ │ ├── NoTweet.svelte │ │ ├── ProfileForm.svelte │ │ ├── Sidebar.svelte │ │ ├── TweetDeleteForm.svelte │ │ ├── TweetForm.svelte │ │ └── TweetList.svelte │ ├── server │ │ ├── actions │ │ │ └── index.ts │ │ ├── lucia.ts │ │ ├── models │ │ │ ├── follow.server.ts │ │ │ ├── tweet.server.ts │ │ │ └── user.server.ts │ │ └── prisma.ts │ └── utils.ts └── routes │ ├── (app) │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── profile │ │ └── [profileId] │ │ │ ├── +layout.server.ts │ │ │ ├── +page.server.ts │ │ │ ├── +page.svelte │ │ │ └── tweets │ │ │ └── [tweetId] │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ ├── tweets │ │ ├── +layout.server.ts │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ └── [tweetId] │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ └── users │ │ ├── +page.server.ts │ │ └── +page.svelte │ └── (auth) │ ├── +layout.svelte │ ├── +page.server.ts │ ├── login │ ├── +layout.svelte │ ├── +page.server.ts │ └── +page.svelte │ └── signup │ ├── +layout.svelte │ ├── +page.server.ts │ └── +page.svelte ├── static └── favicon.png ├── svelte.config.js ├── tailwind.config.js ├── tsconfig.json └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="file:./data.db?connection_limit=1" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:svelte/recommended', 7 | 'prettier' 8 | ], 9 | parser: '@typescript-eslint/parser', 10 | plugins: ['@typescript-eslint'], 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020, 14 | extraFileExtensions: ['.svelte'] 15 | }, 16 | env: { 17 | browser: true, 18 | es2017: true, 19 | node: true 20 | }, 21 | overrides: [ 22 | { 23 | files: ['*.svelte'], 24 | parser: 'svelte-eslint-parser', 25 | parserOptions: { 26 | parser: '@typescript-eslint/parser' 27 | } 28 | } 29 | ] 30 | }; 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | 12 | prisma/data.db -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | resolution-mode=highest 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "pluginSearchDirs": ["."], 8 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.documentSelectors": ["**/*.svelte"], 3 | "editor.formatOnSave": true, 4 | "[svelte]": { "editor.defaultFormatter": "svelte.svelte-vscode" }, 5 | "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" } 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SvelteKit Twitter Clone 2 | 3 | Barebones Twitter clone built with the [Sveltekit](https://kit.svelte.dev), [Lucia Auth](https://lucia-auth.com/), [Prisma](https://www.prisma.io/) and [Faker.js](https://fakerjs.dev/) to mock database data. Uses sqlite for prototyping but can easily be changed to Postgres or MySQL. 4 | 5 | ![Dashboard!](/dashboard.png 'Dashboard') 6 | 7 |

🌟 Features

8 | 9 | - Register, Sign Users 10 | - Follow, Unfollow Users 11 | - Read, Create, Delete Tweets 12 | - Personal feed based on who you follow 13 | - Browser user to Follow 14 | 15 |

📚 Table of Contents

16 | 17 | - [Development](#development) 18 | - [Installation](#installation) 19 | - [Rename env.example](#rename-env) 20 | - [Set up the database](#set-up-the-database) 21 | - [Running the app](#running-the-app) 22 | - [Test user](#test-user) 23 | 24 |

💻 Development

25 | 26 |

📦 Installation

27 | 28 | ```bash 29 | npm install 30 | ``` 31 | 32 |

🗒️Rename env.example to .env

33 | 34 | ``` 35 | DATABASE_URL="file:./data.db?connection_limit=1" 36 | ``` 37 | 38 |

🗄️ Set up the database

39 | 40 | ```bash 41 | # Generate prisma client 42 | npx prisma generate 43 | 44 | # Setup the database 45 | npx prisma db push 46 | 47 | # Seed the database 48 | npx prisma db seed 49 | ``` 50 | 51 |

🚀 Running the app

52 | 53 | ```bash 54 | # development 55 | npm run dev 56 | 57 | # build 58 | npm run build 59 | 60 | # start 61 | npm run preview 62 | ``` 63 | 64 |

🙋‍♂️ Test user for login

65 | 66 | ```js 67 | { 68 | username: "testuser", 69 | password: "password", 70 | } 71 | ``` 72 | -------------------------------------------------------------------------------- /dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihaback/sveltekit-twitter-clone/83412fa11321a1c87070e9de0e535f7cf40c67c8/dashboard.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sveltekit-twitter-clone", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "migrate": "tsx migration/index.ts", 7 | "dev": "vite dev", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "start": "node build", 11 | "setup": "npx prisma generate && npx prisma db push && npx prisma db seed", 12 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 13 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 14 | "lint": "prettier --plugin-search-dir . --check . && eslint .", 15 | "format": "prettier --plugin-search-dir . --write ." 16 | }, 17 | "license": "MIT", 18 | "author": "ihaback (https://ihaback.vercel.app/)", 19 | "dependencies": { 20 | "@lucia-auth/adapter-prisma": "^3.0.0", 21 | "@lucia-auth/adapter-sqlite": "latest", 22 | "@prisma/client": "^5.1.1", 23 | "lucia": "latest" 24 | }, 25 | "devDependencies": { 26 | "@faker-js/faker": "^8.0.2", 27 | "@sveltejs/adapter-node": "^1.0.0", 28 | "@sveltejs/kit": "^1.20.4", 29 | "@typescript-eslint/eslint-plugin": "^5.45.0", 30 | "@typescript-eslint/parser": "^5.45.0", 31 | "autoprefixer": "^10.4.14", 32 | "eslint": "^8.28.0", 33 | "eslint-config-prettier": "^8.5.0", 34 | "eslint-plugin-svelte": "^2.30.0", 35 | "postcss": "^8.4.27", 36 | "prettier": "^2.8.0", 37 | "prettier-plugin-svelte": "^2.10.1", 38 | "svelte": "^4.0.0", 39 | "svelte-check": "^3.4.3", 40 | "svelte-hero-icons": "^5.0.0", 41 | "tailwindcss": "^3.3.3", 42 | "tslib": "^2.4.1", 43 | "tsx": "^3.12.6", 44 | "typescript": "^5.1.6", 45 | "vite": "^4.3.6", 46 | "vite-node": "^0.34.1" 47 | }, 48 | "type": "module", 49 | "prisma": { 50 | "seed": "vite-node ./prisma/seed.ts" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /prisma/migrations/20230806180918_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "username" TEXT NOT NULL 5 | ); 6 | 7 | -- CreateTable 8 | CREATE TABLE "Session" ( 9 | "id" TEXT NOT NULL PRIMARY KEY, 10 | "user_id" TEXT NOT NULL, 11 | "active_expires" BIGINT NOT NULL, 12 | "idle_expires" BIGINT NOT NULL, 13 | CONSTRAINT "Session_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE 14 | ); 15 | 16 | -- CreateTable 17 | CREATE TABLE "Key" ( 18 | "id" TEXT NOT NULL PRIMARY KEY, 19 | "hashed_password" TEXT, 20 | "user_id" TEXT NOT NULL, 21 | CONSTRAINT "Key_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE 22 | ); 23 | 24 | -- CreateTable 25 | CREATE TABLE "Tweet" ( 26 | "id" TEXT NOT NULL PRIMARY KEY, 27 | "body" TEXT NOT NULL, 28 | "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 29 | "updated_at" DATETIME NOT NULL, 30 | "user_id" TEXT NOT NULL, 31 | CONSTRAINT "Tweet_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE 32 | ); 33 | 34 | -- CreateTable 35 | CREATE TABLE "Follows" ( 36 | "follower_id" TEXT NOT NULL, 37 | "following_id" TEXT NOT NULL, 38 | 39 | PRIMARY KEY ("follower_id", "following_id"), 40 | CONSTRAINT "Follows_follower_id_fkey" FOREIGN KEY ("follower_id") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, 41 | CONSTRAINT "Follows_following_id_fkey" FOREIGN KEY ("following_id") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 42 | ); 43 | 44 | -- CreateIndex 45 | CREATE UNIQUE INDEX "User_id_key" ON "User"("id"); 46 | 47 | -- CreateIndex 48 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); 49 | 50 | -- CreateIndex 51 | CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id"); 52 | 53 | -- CreateIndex 54 | CREATE INDEX "Session_user_id_idx" ON "Session"("user_id"); 55 | 56 | -- CreateIndex 57 | CREATE UNIQUE INDEX "Key_id_key" ON "Key"("id"); 58 | 59 | -- CreateIndex 60 | CREATE INDEX "Key_user_id_idx" ON "Key"("user_id"); 61 | -------------------------------------------------------------------------------- /prisma/migrations/20230806184925_image/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA foreign_keys=OFF; 3 | CREATE TABLE "new_User" ( 4 | "id" TEXT NOT NULL PRIMARY KEY, 5 | "username" TEXT NOT NULL, 6 | "image_url" TEXT NOT NULL DEFAULT '' 7 | ); 8 | INSERT INTO "new_User" ("id", "username") SELECT "id", "username" FROM "User"; 9 | DROP TABLE "User"; 10 | ALTER TABLE "new_User" RENAME TO "User"; 11 | CREATE UNIQUE INDEX "User_id_key" ON "User"("id"); 12 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); 13 | PRAGMA foreign_key_check; 14 | PRAGMA foreign_keys=ON; 15 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "sqlite" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | id String @id @unique 15 | username String @unique 16 | image_url String @default("") 17 | 18 | auth_session Session[] 19 | auth_key Key[] 20 | tweets Tweet[] 21 | followed_by Follows[] @relation("following") 22 | following Follows[] @relation("follower") 23 | } 24 | 25 | model Session { 26 | id String @id @unique 27 | user_id String 28 | active_expires BigInt 29 | idle_expires BigInt 30 | user User @relation(references: [id], fields: [user_id], onDelete: Cascade) 31 | 32 | @@index([user_id]) 33 | } 34 | 35 | model Key { 36 | id String @id @unique 37 | hashed_password String? 38 | user_id String 39 | user User @relation(references: [id], fields: [user_id], onDelete: Cascade) 40 | 41 | @@index([user_id]) 42 | } 43 | 44 | model Tweet { 45 | id String @id @default(cuid()) 46 | body String 47 | 48 | created_at DateTime @default(now()) 49 | updated_at DateTime @updatedAt 50 | 51 | user User @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: Cascade) 52 | user_id String 53 | } 54 | 55 | model Follows { 56 | follower User @relation("follower", fields: [follower_id], references: [id]) 57 | follower_id String 58 | following User @relation("following", fields: [following_id], references: [id]) 59 | following_id String 60 | 61 | @@id([follower_id, following_id]) 62 | } 63 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import { lucia } from 'lucia'; 3 | import { prisma as prismaAdapter } from '@lucia-auth/adapter-prisma'; 4 | import { faker } from '@faker-js/faker'; 5 | 6 | const prisma = new PrismaClient(); 7 | 8 | export const auth = await lucia({ 9 | adapter: prismaAdapter(prisma, { 10 | user: 'user', // model User {} 11 | key: 'key', // model Key {} 12 | session: 'session' // model Session {} 13 | }), 14 | env: 'PROD' 15 | }); 16 | 17 | async function seed() { 18 | const username = 'testuser'; 19 | const password = 'password'; 20 | const image_url = await faker.image.avatar(); 21 | const admin_user = await auth.createUser({ 22 | key: { 23 | providerId: 'username', // auth method 24 | providerUserId: username.toLowerCase(), // unique id when using "username" auth method 25 | password // hashed by Lucia 26 | }, 27 | attributes: { 28 | username, 29 | image_url 30 | } 31 | }); 32 | 33 | Array.from({ length: 10 }).map(async () => { 34 | const username = await faker.internet.userName(); 35 | const password = 'password'; 36 | const image_url = await faker.image.avatar(); 37 | const user = await auth.createUser({ 38 | key: { 39 | providerId: 'username', // auth method 40 | providerUserId: username.toLowerCase(), // unique id when using "username" auth method 41 | password // hashed by Lucia 42 | }, 43 | attributes: { 44 | username, 45 | image_url 46 | } 47 | }); 48 | 49 | await prisma.tweet.create({ 50 | data: { 51 | body: await faker.commerce.productDescription(), 52 | user_id: user.userId 53 | } 54 | }); 55 | 56 | await prisma.tweet.create({ 57 | data: { 58 | body: await faker.commerce.productDescription(), 59 | user_id: user.userId 60 | } 61 | }); 62 | 63 | await prisma.follows.create({ 64 | data: { 65 | following_id: user.userId, 66 | follower_id: admin_user.userId 67 | } 68 | }); 69 | }); 70 | 71 | console.log(`Database has been seeded. 🌱`); 72 | } 73 | 74 | seed() 75 | .catch((e) => { 76 | console.error(e); 77 | process.exit(1); 78 | }) 79 | .finally(async () => { 80 | await prisma.$disconnect(); 81 | }); 82 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .feed-padding { 6 | @apply px-4; 7 | } 8 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | interface Locals { 6 | auth: import('lucia').AuthRequest; 7 | } 8 | } 9 | } 10 | 11 | /// 12 | declare global { 13 | namespace Lucia { 14 | type Auth = import('$lib/server/lucia').Auth; 15 | type DatabaseUserAttributes = { 16 | username: string; 17 | }; 18 | type DatabaseSessionAttributes = Record; 19 | } 20 | } 21 | 22 | export {}; 23 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { auth } from '$lib/server/lucia'; 2 | import type { Handle } from '@sveltejs/kit'; 3 | 4 | export const handle: Handle = async ({ event, resolve }) => { 5 | // we can pass `event` because we used the SvelteKit middleware 6 | event.locals.auth = auth.handleRequest(event); 7 | return await resolve(event); 8 | }; 9 | -------------------------------------------------------------------------------- /src/lib/components/AppShell.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |
9 |
12 | 13 |
14 | 15 |
16 |
17 | 18 |
21 |
22 |
23 |
24 | -------------------------------------------------------------------------------- /src/lib/components/AuthForm.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 |
10 |
{ 13 | loading = true; 14 | return async ({ update }) => { 15 | await update(); 16 | loading = false; 17 | }; 18 | }} 19 | class="space-y-6" 20 | > 21 |
22 | 23 |
24 | 33 | {#if form?.message} 34 |
35 | {form.message} 36 |
37 | {/if} 38 |
39 |
40 |
41 | 42 |
43 | 51 |
52 |
53 | {#if type === 'login'} 54 | 60 | {/if} 61 | {#if type === 'signup'} 62 | 68 | {/if} 69 | {#if type === 'login'} 70 |
71 |
72 | Don't have an account? Sign up 73 |
74 |
75 | {/if} 76 | {#if type === 'signup'} 77 |
78 |
79 | Already have an account? Login 80 |
81 |
82 | {/if} 83 |
84 |
85 |
86 | -------------------------------------------------------------------------------- /src/lib/components/Header.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 |

Tweets

10 |

{username}

11 |
{ 15 | loading = true; 16 | return async ({ update }) => { 17 | await update(); 18 | loading = false; 19 | }; 20 | }} 21 | > 22 | 30 |
31 |
32 | -------------------------------------------------------------------------------- /src/lib/components/NoTweet.svelte: -------------------------------------------------------------------------------- 1 | No tweet selected. Select a tweet in the feed. 2 | -------------------------------------------------------------------------------- /src/lib/components/ProfileForm.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 |
15 | 20 | {#if profile_user?.id !== user_id} 21 |
{ 25 | loading = true; 26 | return async ({ update }) => { 27 | await update(); 28 | loading = false; 29 | }; 30 | }} 31 | > 32 | 39 |
40 | {/if} 41 |
42 | {#if form?.message} 43 |
44 |
45 | {form.message} 46 |
47 |
48 | {/if} 49 |
50 |

51 | {profile_user?.username} 52 |

53 | 54 | {profile_user?.id} 55 | 56 |
57 | -------------------------------------------------------------------------------- /src/lib/components/Sidebar.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 48 | -------------------------------------------------------------------------------- /src/lib/components/TweetDeleteForm.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
11 |

{tweet?.user?.username}

12 |

{tweet?.body}

13 | {#if user_id === tweet?.user_id} 14 |
{ 17 | loading = true; 18 | return async ({ update }) => { 19 | await update(); 20 | loading = false; 21 | }; 22 | }} 23 | action="?/remove" 24 | > 25 | 32 |
33 | {/if} 34 | {#if form?.tweetErrorMessage} 35 |
36 |
37 | {form.tweetErrorMessage} 38 |
39 |
40 | {/if} 41 |
42 | -------------------------------------------------------------------------------- /src/lib/components/TweetForm.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
{ 11 | loading = true; 12 | return async ({ update }) => { 13 | await update(); 14 | loading = false; 15 | }; 16 | }} 17 | action="?/create" 18 | > 19 |