├── .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 | 
6 |
7 |
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 |
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 |
25 |
26 |
27 |
28 | ```bash
29 | npm install
30 | ```
31 |
32 |
33 |
34 | ```
35 | DATABASE_URL="file:./data.db?connection_limit=1"
36 | ```
37 |
38 |
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 |
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 |
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 |
23 |
24 |
--------------------------------------------------------------------------------
/src/lib/components/AuthForm.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 |
86 |
--------------------------------------------------------------------------------
/src/lib/components/Header.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 | Tweets
10 | {username}
11 |
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 |
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 |
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 |
42 |
--------------------------------------------------------------------------------
/src/lib/components/TweetList.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 | {#if tweets.length === 0}
15 | No tweets yet
16 | {:else}
17 | {#each tweets as tweet}
18 |
48 | {/each}
49 | {/if}
50 |
--------------------------------------------------------------------------------
/src/lib/server/actions/index.ts:
--------------------------------------------------------------------------------
1 | import { auth } from '$lib/server/lucia';
2 | import { createFollow, deleteFollow, getFollowers } from '$lib/server/models/follow.server';
3 | import { createTweet, deleteTweet, getTweet } from '$lib/server/models/tweet.server';
4 | import { getUserById } from '$lib/server/models/user.server';
5 | import { fail, redirect } from '@sveltejs/kit';
6 |
7 | export async function create({ request, locals }: { request: Request; locals: App.Locals }) {
8 | const session = await locals.auth.validate();
9 | if (!session) throw redirect(302, '/login');
10 |
11 | const form_data = await request.formData();
12 | const body = form_data.get('body');
13 |
14 | if (typeof body !== 'string') {
15 | return fail(400, {
16 | message: 'Invalid tweet'
17 | });
18 | }
19 |
20 | if (body.length < 10) {
21 | return fail(400, {
22 | message: 'To short tweet'
23 | });
24 | }
25 |
26 | try {
27 | await createTweet({ body, user_id: session.user.userId });
28 | } catch (error) {
29 | return fail(400, {
30 | message: 'Could not create tweet'
31 | });
32 | }
33 | }
34 |
35 | export async function remove({
36 | locals,
37 | params
38 | }: {
39 | request: Request;
40 | locals: App.Locals;
41 | params: Partial>;
42 | }) {
43 | const session = await locals.auth.validate();
44 | if (!session) throw redirect(302, '/login');
45 |
46 | if (!params?.tweetId) {
47 | return fail(400, {
48 | tweetErrorMessage: 'Could not delete tweet'
49 | });
50 | }
51 |
52 | const tweet = await getTweet({ id: params.tweetId });
53 |
54 | const is_owner = tweet?.user_id === session.user.userId;
55 |
56 | if (!is_owner) {
57 | if (!params?.tweetId) {
58 | return fail(400, {
59 | tweetErrorMessage: 'Could not delete tweet'
60 | });
61 | }
62 | }
63 |
64 | try {
65 | await deleteTweet({ id: params.tweetId, user_id: session.user.userId });
66 | } catch (error) {
67 | return fail(400, {
68 | tweetErrorMessage: 'Could not delete tweet'
69 | });
70 | }
71 |
72 | if (params?.profileId && params.tweetId) {
73 | throw redirect(302, `/profile/${params.profileId}`);
74 | }
75 |
76 | throw redirect(302, '/tweets');
77 | }
78 |
79 | export async function logout({ locals }: { locals: App.Locals }) {
80 | const session = await locals.auth.validate();
81 | if (!session) return fail(401);
82 | await auth.invalidateSession(session.sessionId); // invalidate session
83 | locals.auth.setSession(null); // remove cookie
84 | throw redirect(302, '/login'); // redirect to login page
85 | }
86 |
87 | export async function follow({
88 | locals,
89 | params
90 | }: {
91 | locals: App.Locals;
92 | params: Partial>;
93 | }) {
94 | const session = await locals.auth.validate();
95 | if (!session) throw redirect(302, '/login');
96 |
97 | if (session.user.userId === params?.profileId) {
98 | return fail(400, {
99 | message: 'Not allowed to follow yourself'
100 | });
101 | }
102 |
103 | const profile_user = await getUserById(params?.profileId as string);
104 |
105 | const following = await getFollowers({ following_id: params.profileId as string });
106 |
107 | const is_following = following.some((x) => x.follower_id === session.user.userId);
108 |
109 | if (is_following) {
110 | try {
111 | await deleteFollow({
112 | following_id: profile_user?.id as string,
113 | follower_id: session.user.userId
114 | });
115 | } catch (error) {
116 | return fail(500, {
117 | message: 'Could not unfollow'
118 | });
119 | }
120 | } else {
121 | try {
122 | await createFollow({
123 | following_id: profile_user?.id as string,
124 | follower_id: session.user.userId
125 | });
126 | } catch (error) {
127 | return fail(500, {
128 | message: 'Could not follow'
129 | });
130 | }
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/src/lib/server/lucia.ts:
--------------------------------------------------------------------------------
1 | import { lucia } from 'lucia';
2 | import { sveltekit } from 'lucia/middleware';
3 | import { dev } from '$app/environment';
4 | import { prisma as prismaAdapter } from '@lucia-auth/adapter-prisma';
5 | import { prisma } from './prisma';
6 |
7 | export const auth = lucia({
8 | adapter: prismaAdapter(prisma, {
9 | user: 'user', // model User {}
10 | key: 'key', // model Key {}
11 | session: 'session' // model Session {}
12 | }),
13 | middleware: sveltekit(),
14 | env: dev ? 'DEV' : 'PROD',
15 | getUserAttributes: (data) => {
16 | return {
17 | username: data.username
18 | };
19 | }
20 | });
21 |
22 | export type Auth = typeof auth;
23 |
--------------------------------------------------------------------------------
/src/lib/server/models/follow.server.ts:
--------------------------------------------------------------------------------
1 | import type { User } from '@prisma/client';
2 | import { prisma } from '../prisma';
3 |
4 | export function createFollow({
5 | following_id,
6 | follower_id
7 | }: {
8 | following_id: User['id'];
9 | follower_id: User['id'];
10 | }) {
11 | return prisma.follows.create({
12 | data: {
13 | following_id,
14 | follower_id
15 | }
16 | });
17 | }
18 |
19 | export function deleteFollow({
20 | following_id,
21 | follower_id
22 | }: {
23 | following_id: User['id'];
24 | follower_id: User['id'];
25 | }) {
26 | return prisma.follows.delete({
27 | where: {
28 | follower_id_following_id: {
29 | follower_id,
30 | following_id
31 | }
32 | }
33 | });
34 | }
35 |
36 | export function getFollowers({ following_id }: { following_id: User['id'] }) {
37 | return prisma.follows.findMany({
38 | where: { following_id },
39 | select: {
40 | follower_id: true
41 | }
42 | });
43 | }
44 |
45 | export function getFollows({ follower_id }: { follower_id: User['id'] }) {
46 | return prisma.follows.findMany({
47 | where: { follower_id },
48 | select: {
49 | following_id: true
50 | }
51 | });
52 | }
53 |
--------------------------------------------------------------------------------
/src/lib/server/models/tweet.server.ts:
--------------------------------------------------------------------------------
1 | import type { User, Tweet } from '@prisma/client';
2 | import { prisma } from '../prisma';
3 |
4 | export function getTweet({ id }: Pick) {
5 | return prisma.tweet.findFirst({
6 | select: {
7 | id: true,
8 | body: true,
9 | user_id: true,
10 | user: { select: { username: true } }
11 | },
12 | where: { id }
13 | });
14 | }
15 |
16 | export function getTweetListItems({ user_ids }: { user_ids: User['id'][] }) {
17 | return prisma.tweet.findMany({
18 | where: { user_id: { in: user_ids } },
19 | select: {
20 | id: true,
21 | body: true,
22 | user_id: true,
23 | user: {
24 | select: {
25 | username: true,
26 | image_url: true
27 | }
28 | }
29 | },
30 | orderBy: { updated_at: 'desc' }
31 | });
32 | }
33 |
34 | export function getAllTweetListItems() {
35 | return prisma.tweet.findMany({
36 | select: {
37 | id: true,
38 | body: true,
39 | user_id: true,
40 | user: {
41 | select: {
42 | username: true,
43 | image_url: true
44 | }
45 | }
46 | },
47 | orderBy: { updated_at: 'desc' }
48 | });
49 | }
50 |
51 | export function createTweet({
52 | body,
53 | user_id
54 | }: Pick & {
55 | user_id: User['id'];
56 | }) {
57 | return prisma.tweet.create({
58 | data: {
59 | body,
60 | user: {
61 | connect: {
62 | id: user_id
63 | }
64 | }
65 | }
66 | });
67 | }
68 |
69 | export function deleteTweet({ id, user_id }: Pick & { user_id: User['id'] }) {
70 | return prisma.tweet.deleteMany({
71 | where: { id, user_id }
72 | });
73 | }
74 |
--------------------------------------------------------------------------------
/src/lib/server/models/user.server.ts:
--------------------------------------------------------------------------------
1 | import type { User } from '@prisma/client';
2 | import { prisma } from '../prisma';
3 |
4 | export async function getUserById(id: User['id']) {
5 | return prisma.user.findUnique({ where: { id } });
6 | }
7 |
8 | export async function getUsers() {
9 | return prisma.user.findMany();
10 | }
11 |
--------------------------------------------------------------------------------
/src/lib/server/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client';
2 |
3 | const prisma = new PrismaClient();
4 |
5 | export { prisma };
6 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | export function classNames(...classes: string[]): string {
2 | return classes.filter(Boolean).join(' ');
3 | }
4 |
--------------------------------------------------------------------------------
/src/routes/(app)/+layout.server.ts:
--------------------------------------------------------------------------------
1 | import { redirect } from '@sveltejs/kit';
2 |
3 | import type { LayoutServerLoad } from './$types';
4 | import { getUsers } from '$lib/server/models/user.server';
5 |
6 | export const load: LayoutServerLoad = async ({ locals }) => {
7 | const session = await locals.auth.validate();
8 | if (!session) throw redirect(302, '/login');
9 |
10 | const users = await getUsers();
11 |
12 | return {
13 | user_id: session.user.userId,
14 | username: session.user.username,
15 | users
16 | };
17 | };
18 |
--------------------------------------------------------------------------------
/src/routes/(app)/+layout.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 | app
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/routes/(app)/profile/[profileId]/+layout.server.ts:
--------------------------------------------------------------------------------
1 | import { redirect } from '@sveltejs/kit';
2 |
3 | import type { LayoutServerLoad } from './$types';
4 | import { getTweetListItems } from '$lib/server/models/tweet.server';
5 | import { getUserById } from '$lib/server/models/user.server';
6 | import { getFollowers } from '$lib/server/models/follow.server';
7 |
8 | export const load: LayoutServerLoad = async ({ locals, params }) => {
9 | const session = await locals.auth.validate();
10 | if (!session) throw redirect(302, '/login');
11 |
12 | const tweets = await getTweetListItems({ user_ids: [params.profileId] });
13 |
14 | const profile_user = await getUserById(params?.profileId);
15 |
16 | const following = await getFollowers({ following_id: params.profileId });
17 |
18 | const is_following = following?.some((x) => x.follower_id === session.user.userId);
19 |
20 | return {
21 | tweets,
22 | profile_user,
23 | is_following
24 | };
25 | };
26 |
--------------------------------------------------------------------------------
/src/routes/(app)/profile/[profileId]/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { follow, logout } from '$lib/server/actions';
2 | import type { Actions } from '@sveltejs/kit';
3 |
4 | export const actions: Actions = {
5 | follow,
6 | logout
7 | };
8 |
--------------------------------------------------------------------------------
/src/routes/(app)/profile/[profileId]/+page.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/routes/(app)/profile/[profileId]/tweets/[tweetId]/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { redirect, type Actions } from '@sveltejs/kit';
2 |
3 | import { getTweet } from '$lib/server/models/tweet.server';
4 | import type { PageServerLoad } from './$types';
5 | import { logout, remove, follow } from '$lib/server/actions';
6 |
7 | export const load: PageServerLoad = async ({ locals, params }) => {
8 | const session = await locals.auth.validate();
9 | if (!session) throw redirect(302, '/login');
10 |
11 | const tweet = await getTweet({ id: params.tweetId });
12 |
13 | return { tweet, user_id: session.user.userId };
14 | };
15 |
16 | export const actions: Actions = {
17 | follow,
18 | remove,
19 | logout
20 | };
21 |
--------------------------------------------------------------------------------
/src/routes/(app)/profile/[profileId]/tweets/[tweetId]/+page.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/routes/(app)/tweets/+layout.server.ts:
--------------------------------------------------------------------------------
1 | import { redirect } from '@sveltejs/kit';
2 |
3 | import type { LayoutServerLoad } from './$types';
4 | import { getTweetListItems } from '$lib/server/models/tweet.server';
5 | import { getFollows } from '$lib/server/models/follow.server';
6 |
7 | export const load: LayoutServerLoad = async ({ locals }) => {
8 | const session = await locals.auth.validate();
9 | if (!session) throw redirect(302, '/login');
10 |
11 | const follows = await getFollows({ follower_id: session.user.userId });
12 |
13 | const follows_ids = [session.user.userId, ...follows.map((x) => x.following_id)];
14 |
15 | const tweets = await getTweetListItems({ user_ids: follows_ids });
16 |
17 | return {
18 | tweets
19 | };
20 | };
21 |
--------------------------------------------------------------------------------
/src/routes/(app)/tweets/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { create, logout } from '$lib/server/actions';
2 | import type { Actions } from '@sveltejs/kit';
3 |
4 | export const actions: Actions = {
5 | create,
6 | logout
7 | };
8 |
--------------------------------------------------------------------------------
/src/routes/(app)/tweets/+page.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/routes/(app)/tweets/[tweetId]/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { redirect, type Actions } from '@sveltejs/kit';
2 |
3 | import { getTweet } from '$lib/server/models/tweet.server';
4 | import type { PageServerLoad } from './$types';
5 | import { create, remove, logout } from '$lib/server/actions';
6 |
7 | export const load: PageServerLoad = async ({ locals, params }) => {
8 | const session = await locals.auth.validate();
9 | if (!session) throw redirect(302, '/login');
10 |
11 | const tweet = await getTweet({ id: params.tweetId });
12 |
13 | return { tweet, user_id: session.user.userId };
14 | };
15 |
16 | export const actions: Actions = {
17 | create,
18 | remove,
19 | logout
20 | };
21 |
--------------------------------------------------------------------------------
/src/routes/(app)/tweets/[tweetId]/+page.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/routes/(app)/users/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { logout } from '$lib/server/actions';
2 | import type { Actions } from '@sveltejs/kit';
3 |
4 | export const actions: Actions = {
5 | logout
6 | };
7 |
--------------------------------------------------------------------------------
/src/routes/(app)/users/+page.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/src/routes/(auth)/+layout.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/routes/(auth)/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { redirect } from '@sveltejs/kit';
2 |
3 | import type { PageServerLoad, Actions } from './$types';
4 | import { logout } from '$lib/server/actions';
5 |
6 | export const load: PageServerLoad = async ({ locals }) => {
7 | const session = await locals.auth.validate();
8 | if (!session) throw redirect(302, '/login');
9 | if (session) throw redirect(302, '/tweets');
10 | };
11 |
12 | export const actions: Actions = {
13 | logout
14 | };
15 |
--------------------------------------------------------------------------------
/src/routes/(auth)/login/+layout.svelte:
--------------------------------------------------------------------------------
1 |
2 | Login
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/routes/(auth)/login/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { auth } from '$lib/server/lucia';
2 | import { LuciaError } from 'lucia';
3 | import { fail, redirect } from '@sveltejs/kit';
4 |
5 | import type { PageServerLoad, Actions } from './$types';
6 |
7 | export const load: PageServerLoad = async ({ locals }) => {
8 | const session = await locals.auth.validate();
9 | if (session) throw redirect(302, '/tweets');
10 | return {};
11 | };
12 |
13 | export const actions: Actions = {
14 | default: async ({ request, locals }) => {
15 | const form_data = await request.formData();
16 | const username = form_data.get('username');
17 | const password = form_data.get('password');
18 | // basic check
19 | if (typeof username !== 'string' || username.length < 1 || username.length > 31) {
20 | return fail(400, {
21 | message: 'Invalid username'
22 | });
23 | }
24 | if (typeof password !== 'string' || password.length < 1 || password.length > 255) {
25 | return fail(400, {
26 | message: 'Invalid password'
27 | });
28 | }
29 | try {
30 | // find user by key
31 | // and validate password
32 | const key = await auth.useKey('username', username.toLowerCase(), password);
33 | const session = await auth.createSession({
34 | userId: key.userId,
35 | attributes: {}
36 | });
37 | locals.auth.setSession(session); // set session cookie
38 | } catch (e) {
39 | if (
40 | e instanceof LuciaError &&
41 | (e.message === 'AUTH_INVALID_KEY_ID' || e.message === 'AUTH_INVALID_PASSWORD')
42 | ) {
43 | // user does not exist
44 | // or invalid password
45 | return fail(400, {
46 | message: 'Incorrect username or password'
47 | });
48 | }
49 | return fail(500, {
50 | message: 'An unknown error occurred'
51 | });
52 | }
53 | // redirect to
54 | // make sure you don't throw inside a try/catch block!
55 | throw redirect(302, '/tweets');
56 | }
57 | };
58 |
--------------------------------------------------------------------------------
/src/routes/(auth)/login/+page.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/routes/(auth)/signup/+layout.svelte:
--------------------------------------------------------------------------------
1 |
2 | Signup
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/routes/(auth)/signup/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { auth } from '$lib/server/lucia';
2 | import { fail, redirect } from '@sveltejs/kit';
3 | import { Prisma } from '@prisma/client';
4 |
5 | import type { PageServerLoad, Actions } from './$types';
6 |
7 | export const load: PageServerLoad = async ({ locals }) => {
8 | const session = await locals.auth.validate();
9 | if (session) throw redirect(302, '/tweets');
10 | return {};
11 | };
12 |
13 | export const actions: Actions = {
14 | default: async ({ request, locals }) => {
15 | const form_data = await request.formData();
16 | const username = form_data.get('username');
17 | const password = form_data.get('password');
18 | // basic check
19 | if (typeof username !== 'string' || username.length < 4 || username.length > 31) {
20 | return fail(400, {
21 | message: 'Invalid username'
22 | });
23 | }
24 | if (typeof password !== 'string' || password.length < 6 || password.length > 255) {
25 | return fail(400, {
26 | message: 'Invalid password'
27 | });
28 | }
29 | try {
30 | const user = await auth.createUser({
31 | key: {
32 | providerId: 'username', // auth method
33 | providerUserId: username.toLowerCase(), // unique id when using "username" auth method
34 | password // hashed by Lucia
35 | },
36 | attributes: {
37 | username
38 | }
39 | });
40 | const session = await auth.createSession({
41 | userId: user.userId,
42 | attributes: {}
43 | });
44 | locals.auth.setSession(session); // set session cookie
45 | } catch (e) {
46 | // check for unique constraint error in user table
47 | if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
48 | return fail(400, {
49 | message: 'Username already taken'
50 | });
51 | }
52 | return fail(500, {
53 | message: 'An unknown error occurred'
54 | });
55 | }
56 | // redirect to
57 | // make sure you don't throw inside a try/catch block!
58 | throw redirect(302, '/tweets');
59 | }
60 | };
61 |
--------------------------------------------------------------------------------
/src/routes/(auth)/signup/+page.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/static/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ihaback/sveltekit-twitter-clone/83412fa11321a1c87070e9de0e535f7cf40c67c8/static/favicon.png
--------------------------------------------------------------------------------
/svelte.config.js:
--------------------------------------------------------------------------------
1 | import adapter from '@sveltejs/adapter-node';
2 | import { vitePreprocess } from '@sveltejs/kit/vite';
3 |
4 | /** @type {import('@sveltejs/kit').Config} */
5 | const config = {
6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors
7 | // for more information about preprocessors
8 | preprocess: vitePreprocess(),
9 |
10 | kit: {
11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
12 | // If your environment is not supported or you settled on a specific environment, switch out the adapter.
13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters.
14 | adapter: adapter(),
15 | csp: {
16 | directives: {
17 | 'script-src': ['self'],
18 | 'img-src': ['self', 'avatars.githubusercontent.com', 'cloudflare-ipfs.com', 'ipfs.io'],
19 | 'form-action': ['self'],
20 | 'frame-src': ['self'],
21 | 'connect-src': ['self'],
22 | 'font-src': ['self'],
23 | 'frame-ancestors': ['self'],
24 | 'child-src': ['self'],
25 | 'base-uri': ['self'],
26 | 'object-src': ['self']
27 | }
28 | }
29 | }
30 | };
31 |
32 | export default config;
33 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ['./src/**/*.{html,js,svelte,ts}'],
4 | theme: {
5 | extend: {}
6 | },
7 | plugins: []
8 | };
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./.svelte-kit/tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true,
5 | "checkJs": true,
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "resolveJsonModule": true,
9 | "skipLibCheck": true,
10 | "sourceMap": true,
11 | "strict": true
12 | }
13 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
14 | //
15 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
16 | // from the referenced tsconfig.json - TypeScript does not merge them in
17 | }
18 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { sveltekit } from '@sveltejs/kit/vite';
2 | import { defineConfig } from 'vite';
3 |
4 | export default defineConfig({
5 | plugins: [sveltekit()]
6 | });
7 |
--------------------------------------------------------------------------------