feat: init

This commit is contained in:
diced
2023-06-24 00:24:47 -07:00
commit 49e3bd5e9b
33 changed files with 16853 additions and 0 deletions

3
.env Normal file
View File

@@ -0,0 +1,3 @@
SESSION_SECRET="mysupersecret"
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/zipline4-1?schema=public"
DEBUG=zipline

49
.eslintrc.js Normal file
View File

@@ -0,0 +1,49 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'@remix-run/eslint-config',
'@remix-run/eslint-config/node',
'plugin:prettier/recommended',
'plugin:@typescript-eslint/recommended',
],
root: true,
plugins: ['unused-imports', '@typescript-eslint'],
parser: '@typescript-eslint/parser',
rules: {
'linebreak-style': ['error', 'unix'],
quotes: [
'error',
'single',
{
avoidEscape: true,
},
],
semi: ['error', 'always'],
'comma-dangle': ['error', 'always-multiline'],
'jsx-quotes': ['error', 'prefer-single'],
indent: 'off',
'react/prop-types': 'off',
'react-hooks/rules-of-hooks': 'off',
'react-hooks/exhaustive-deps': 'off',
'react/jsx-uses-react': 'warn',
'react/jsx-uses-vars': 'warn',
'react/no-danger-with-children': 'warn',
'react/no-deprecated': 'warn',
'react/no-direct-mutation-state': 'warn',
'react/no-is-mounted': 'warn',
'react/no-typos': 'error',
'react/react-in-jsx-scope': 'off',
'react/require-render-return': 'error',
'react/style-prop-object': 'warn',
'jsx-a11y/alt-text': 'off',
'react/display-name': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': [
'error',
{ vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' },
],
'@typescript-eslint/ban-ts-comment': 'off',
},
};

33
.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
node_modules
/.cache
/build
/public/build
# yarn
.yarn/*
!.yarn/releases
!.yarn/plugins
# misc
.DS_Store
*.pem
.idea
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# zipline
uploads*/
dist/

File diff suppressed because one or more lines are too long

874
.yarn/releases/yarn-3.6.0.cjs vendored Executable file

File diff suppressed because one or more lines are too long

9
.yarnrc.yml Normal file
View File

@@ -0,0 +1,9 @@
checksumBehavior: update
nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
spec: "@yarnpkg/plugin-workspace-tools"
yarnPath: .yarn/releases/yarn-3.6.0.cjs

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 dicedtomato
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# Zipline 4
! This is a work in progress, the database is not final and is subject to change without a migration. !
Roadmap for v4: https://diced.notion.site/Zipline-v4-Roadmap-058aceb8a35140e7af4c726560aa3db1?pvs=4

71
package.json Normal file
View File

@@ -0,0 +1,71 @@
{
"name": "zipline",
"private": true,
"license": "MIT",
"sideEffects": false,
"version": "4.0.0-dev.1",
"scripts": {
"build": "run-s build:*",
"build:remix": "remix build",
"build:server": "tsup",
"dev": "run-p dev:remix & (run-s dev:build && run-s dev:server)",
"dev:build": "cross-env NODE_ENV=development run-s build:server",
"dev:remix": "cross-env NODE_ENV=development remix watch",
"dev:server": "cross-env NODE_ENV=development node --require ./node_modules/dotenv/config ./build/server.js",
"start": "node ./server.mjs",
"lint": "eslint --cache --ignore-path .gitignore --fix .",
"format": "prettier --write --ignore-path .gitignore .",
"validate": "run-p lint format"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/server": "^11.11.0",
"@mantine/core": "^6.0.14",
"@mantine/dates": "^6.0.14",
"@mantine/dropzone": "^6.0.14",
"@mantine/form": "^6.0.14",
"@mantine/hooks": "^6.0.14",
"@mantine/modals": "^6.0.14",
"@mantine/notifications": "^6.0.14",
"@mantine/prism": "^6.0.14",
"@mantine/remix": "^6.0.14",
"@prisma/client": "4.16.1",
"@prisma/internals": "^4.16.1",
"@prisma/migrate": "^4.16.1",
"@remix-run/express": "^1.17.1",
"@remix-run/node": "^1.16.1",
"@remix-run/react": "^1.16.1",
"@remix-run/v1-route-convention": "^0.1.2",
"@types/express": "^4.17.17",
"bytes": "^3.1.2",
"colorette": "^2.0.20",
"dayjs": "^1.11.8",
"express": "^4.18.2",
"isbot": "^3.6.10",
"ms": "^2.1.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"znv": "^0.3.2",
"zod": "^3.21.4"
},
"devDependencies": {
"@remix-run/dev": "^1.16.1",
"@remix-run/eslint-config": "^1.16.1",
"@types/bytes": "^3.1.1",
"@types/node": "^20.3.1",
"@types/react": "^18.2.7",
"@types/react-dom": "^18.2.4",
"@types/signale": "^1.4.4",
"cross-env": "^7.0.3",
"dotenv": "^16.1.3",
"eslint": "^8.41.0",
"npm-run-all": "^4.1.5",
"prisma": "^4.16.1",
"tsup": "^7.0.0",
"typescript": "^5.1.3"
},
"engines": {
"node": ">=18"
},
"packageManager": "yarn@3.6.0"
}

6
prettier.config.cjs Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('prettier').Config} */
module.exports = {
singleQuote: true,
jsxSingleQuote: true,
printWidth: 110,
};

View File

@@ -0,0 +1,234 @@
-- CreateEnum
CREATE TYPE "OAuthProviderType" AS ENUM ('DISCORD', 'GOOGLE', 'GITHUB');
-- CreateEnum
CREATE TYPE "LimitType" AS ENUM ('UPLOAD_COUNT', 'UPLOAD_SIZE', 'SHORTEN_COUNT');
-- CreateEnum
CREATE TYPE "LimitTimeframe" AS ENUM ('SECONDLY', 'MINUTELY', 'HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY');
-- CreateEnum
CREATE TYPE "IncompleteFileStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETE', 'FAILED');
-- CreateTable
CREATE TABLE "zipline_meta" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"firstSetup" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "zipline_meta_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT,
"avatar" TEXT,
"token" TEXT NOT NULL,
"administrator" BOOLEAN NOT NULL DEFAULT false,
"ziplineId" TEXT NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OAuthProvider" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
"provider" "OAuthProviderType" NOT NULL,
"accessToken" TEXT NOT NULL,
"refreshToken" TEXT NOT NULL,
"expiresIn" INTEGER NOT NULL,
"scope" TEXT NOT NULL,
"tokenType" TEXT NOT NULL,
"profile" JSONB NOT NULL,
CONSTRAINT "OAuthProvider_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserLimit" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"type" "LimitType" NOT NULL,
"value" INTEGER NOT NULL,
"timeframe" "LimitTimeframe" NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "UserLimit_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "File" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletesAt" TIMESTAMP(3),
"name" TEXT NOT NULL,
"originalName" TEXT NOT NULL,
"path" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"type" TEXT NOT NULL,
"views" INTEGER NOT NULL DEFAULT 0,
"favorite" BOOLEAN NOT NULL DEFAULT false,
"password" TEXT,
"zeroWidthSpace" TEXT,
"userId" TEXT,
"folderId" TEXT,
CONSTRAINT "File_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Folder" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "IncompleteFile" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"status" "IncompleteFileStatus" NOT NULL,
"chunksTotal" INTEGER NOT NULL,
"chunksComplete" INTEGER NOT NULL,
"metadata" JSONB NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "IncompleteFile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT NOT NULL,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Url" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"vanity" TEXT,
"destination" TEXT NOT NULL,
"name" TEXT NOT NULL,
"zeroWidthSpace" TEXT,
"userId" TEXT,
CONSTRAINT "Url_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Metric" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"data" JSONB NOT NULL,
"ziplineId" TEXT NOT NULL,
CONSTRAINT "Metric_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Invite" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"expiresAt" TIMESTAMP(3),
"code" TEXT NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
"inviterId" TEXT NOT NULL,
"ziplineId" TEXT NOT NULL,
CONSTRAINT "Invite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_FileToTag" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_token_key" ON "User"("token");
-- CreateIndex
CREATE UNIQUE INDEX "OAuthProvider_userId_provider_key" ON "OAuthProvider"("userId", "provider");
-- CreateIndex
CREATE UNIQUE INDEX "UserLimit_type_key" ON "UserLimit"("type");
-- CreateIndex
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Url_name_key" ON "Url"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Invite_code_key" ON "Invite"("code");
-- CreateIndex
CREATE UNIQUE INDEX "_FileToTag_AB_unique" ON "_FileToTag"("A", "B");
-- CreateIndex
CREATE INDEX "_FileToTag_B_index" ON "_FileToTag"("B");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_ziplineId_fkey" FOREIGN KEY ("ziplineId") REFERENCES "zipline_meta"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthProvider" ADD CONSTRAINT "OAuthProvider_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserLimit" ADD CONSTRAINT "UserLimit_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "File" ADD CONSTRAINT "File_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "File" ADD CONSTRAINT "File_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IncompleteFile" ADD CONSTRAINT "IncompleteFile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Url" ADD CONSTRAINT "Url_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Metric" ADD CONSTRAINT "Metric_ziplineId_fkey" FOREIGN KEY ("ziplineId") REFERENCES "zipline_meta"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_ziplineId_fkey" FOREIGN KEY ("ziplineId") REFERENCES "zipline_meta"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_FileToTag" ADD CONSTRAINT "_FileToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "File"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_FileToTag" ADD CONSTRAINT "_FileToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

213
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,213 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Zipline {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
firstSetup Boolean @default(true)
metrics Metric[]
users User[]
invite Invite[]
@@map("zipline_meta")
}
model User {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
username String @unique
password String?
avatar String?
token String @unique
administrator Boolean @default(false)
files File[]
urls Url[]
folders Folder[]
limits UserLimit[]
invites Invite[]
oauthProviders OAuthProvider[]
IncompleteFile IncompleteFile[]
Zipline Zipline @relation(fields: [ziplineId], references: [id], onDelete: Cascade, onUpdate: Cascade)
ziplineId String
}
model OAuthProvider {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
provider OAuthProviderType
accessToken String
refreshToken String
expiresIn Int
scope String
tokenType String
profile Json
user User @relation(fields: [userId], references: [id])
@@unique([userId, provider])
}
enum OAuthProviderType {
DISCORD
GOOGLE
GITHUB
}
model UserLimit {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
type LimitType @unique
value Int
timeframe LimitTimeframe
User User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
userId String
}
enum LimitType {
UPLOAD_COUNT
UPLOAD_SIZE
SHORTEN_COUNT
}
enum LimitTimeframe {
SECONDLY
MINUTELY
HOURLY
DAILY
WEEKLY
MONTHLY
YEARLY
}
model File {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletesAt DateTime?
name String // name shown on dashboard
originalName String // original name of file when uploaded
path String // path it's stored on the server
size Int
type String
views Int @default(0)
favorite Boolean @default(false)
password String?
zeroWidthSpace String?
tags Tag[]
User User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade)
userId String?
Folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull, onUpdate: Cascade)
folderId String?
}
model Folder {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
files File[]
User User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
userId String
}
model IncompleteFile {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status IncompleteFileStatus
chunksTotal Int
chunksComplete Int
metadata Json
User User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
userId String
}
enum IncompleteFileStatus {
PENDING
PROCESSING
COMPLETE
FAILED
}
model Tag {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @unique
color String
files File[]
}
model Url {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vanity String?
destination String
name String @unique
zeroWidthSpace String?
User User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade)
userId String?
}
model Metric {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
data Json
Zipline Zipline @relation(fields: [ziplineId], references: [id], onDelete: Cascade, onUpdate: Cascade)
ziplineId String
}
model Invite {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
expiresAt DateTime?
code String @unique
used Boolean @default(false)
inviter User @relation(fields: [inviterId], references: [id], onDelete: Cascade, onUpdate: Cascade)
inviterId String
Zipline Zipline @relation(fields: [ziplineId], references: [id], onDelete: Cascade, onUpdate: Cascade)
ziplineId String
}

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

27
remix.config.js Normal file
View File

@@ -0,0 +1,27 @@
const { createRoutesFromFolders } = require('@remix-run/v1-route-convention');
/**
* @type {import('@remix-run/dev').AppConfig}
*/
module.exports = {
ignoredRouteFiles: ['**/.*'],
appDirectory: 'src/app',
// assetsBuildDirectory: 'public/build',
// serverBuildPath: 'build/index.js',
serverModuleFormat: 'cjs',
future: {
unstable_dev: true,
v2_routeConvention: true,
v2_errorBoundary: true,
v2_meta: true,
v2_normalizeFormMethod: true,
v2_headers: true,
},
publicPath: '/modules/',
// use directory structure.
routes(defineRoutes) {
return createRoutesFromFolders(defineRoutes, {
appDirectory: 'src/app',
});
},
};

2
remix.env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="@remix-run/dev" />
/// <reference types="@remix-run/node/globals" />

30
src/app/db.server.ts Normal file
View File

@@ -0,0 +1,30 @@
import { PrismaClient } from '@prisma/client';
import { log } from 'src/lib/logger';
let prisma: PrismaClient;
declare global {
var __db__: PrismaClient;
}
if (process.env.NODE_ENV === 'production') {
prisma = getClient();
} else {
if (!global.__db__) {
global.__db__ = getClient();
}
prisma = global.__db__;
}
function getClient() {
const logger = log('db');
logger.info('connecting to database', process.env.DATABASE_URL);
const client = new PrismaClient();
client.$connect();
return client;
}
export { prisma };

11
src/app/entry.client.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { RemixBrowser } from '@remix-run/react';
import { hydrate } from 'react-dom';
import { ClientProvider } from '@mantine/remix';
hydrate(
<ClientProvider>
<RemixBrowser />
</ClientProvider>,
document
);

21
src/app/entry.server.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { renderToString } from 'react-dom/server';
import { RemixServer } from '@remix-run/react';
import type { EntryContext } from '@remix-run/node';
import { injectStyles, createStylesServer } from '@mantine/remix';
const server = createStylesServer();
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
let markup = renderToString(<RemixServer context={remixContext} url={request.url} />);
responseHeaders.set('Content-Type', 'text/html');
return new Response(`<!DOCTYPE html>${injectStyles(markup, server)}`, {
status: responseStatusCode,
headers: responseHeaders,
});
}

32
src/app/root.tsx Normal file
View File

@@ -0,0 +1,32 @@
import type { V2_MetaFunction } from '@remix-run/node';
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react';
import { MantineProvider, createEmotionCache } from '@mantine/core';
import { StylesPlaceholder } from '@mantine/remix';
export const meta: V2_MetaFunction = () => [
{ charSet: 'utf-8' },
{ title: 'Zipline' },
{ name: 'viewport', content: 'width=device-width,initial-scale=1' },
];
createEmotionCache({ key: 'mantine' });
export default function App() {
return (
<MantineProvider withGlobalStyles withNormalizeCSS>
<html lang='en'>
<head>
<StylesPlaceholder />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
</MantineProvider>
);
}

View File

@@ -0,0 +1,13 @@
import { LoaderArgs, json } from '@remix-run/node';
import { prisma } from '~/db.server';
export async function loader({ context, request }: LoaderArgs) {
try {
// test database connection
await prisma.user.count();
return json({ pong: true }, { status: 200 });
} catch (e) {
return json({ pong: false }, { status: 500 });
}
}

22
src/app/routes/index.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { LoaderArgs, json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { prisma } from '~/db.server';
export async function loader({}: LoaderArgs) {
let zipline = await prisma.zipline.findFirst();
if (!zipline) {
zipline = await prisma.zipline.create({ data: {} });
}
return json({ zipline });
}
export default function Index() {
const { zipline } = useLoaderData<typeof loader>();
return (
<div>
<pre>{JSON.stringify(zipline, null, 2)}</pre>
</div>
);
}

25
src/app/session.server.ts Normal file
View File

@@ -0,0 +1,25 @@
import { createCookieSessionStorage } from "@remix-run/node";
let sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) {
throw new Error("SESSION_SECRET must be set");
}
export let sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
secrets: [sessionSecret],
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
},
});
const USER_SESSION_KEY = "userId";
export async function getSession(request: Request) {
const cookie = request.headers.get("Cookie");
return sessionStorage.getSession(cookie);
}

3
src/app/sleep.ts Normal file
View File

@@ -0,0 +1,3 @@
export function sleep<T>(ms: number, value: T) {
return new Promise<T>((resolve) => setTimeout(() => resolve(value), ms));
}

9
src/lib/config/Config.ts Normal file
View File

@@ -0,0 +1,9 @@
export interface Config {
core: ConfigCore;
}
export interface ConfigCore {
port: number;
sessionSecret: string;
databaseUrl: string;
}

11
src/lib/config/convert.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { ValidatedEnv } from './read';
export function convertEnv(env: ValidatedEnv) {
return {
core: {
port: env.PORT,
sessionSecret: env.SESSION_SECRET,
databaseUrl: env.DATABASE_URL,
},
};
}

19
src/lib/config/read.ts Normal file
View File

@@ -0,0 +1,19 @@
import { log } from 'src/lib/logger';
import { parseEnv } from 'znv';
import { z } from 'zod';
const logger = log('config').c('read');
export function readEnv() {
logger.debug('reading env');
const validation = parseEnv(process.env, {
PORT: z.number().default(3000),
SESSION_SECRET: z.string(),
DATABASE_URL: z.string(),
});
return validation;
}
export type ValidatedEnv = ReturnType<typeof readEnv>;

72
src/lib/logger.ts Normal file
View File

@@ -0,0 +1,72 @@
import dayjs from 'dayjs';
import { blue, green, red, yellow, gray, white, bold } from 'colorette';
export type LoggerLevel = 'info' | 'warn' | 'error' | 'debug' | 'trace';
export function log(name: string) {
return new Logger(name);
}
export default class Logger {
public constructor(public name: string) {}
// Creates child of this logger
public c(name: string) {
return new Logger(`${this.name}::${name}`);
}
private format(message: string, level: LoggerLevel) {
const timestamp = dayjs().format('YYYY-MM-DDTHH:mm:ss');
return `${gray('[')}${timestamp} ${this.formatLevel(level)} ${this.name}${gray(']')} ${message}`;
}
private formatLevel(level: LoggerLevel) {
switch (level) {
case 'info':
return green('INFO ');
case 'warn':
return yellow('WARN ');
case 'error':
return red('ERROR');
case 'debug':
return yellow(bold('DEBUG'));
case 'trace':
return gray(bold('TRACE'));
default:
return white(bold('?????'));
}
}
private write(message: string, level: LoggerLevel) {
process.stdout.write(`${this.format(message, level)}\n`);
}
public info(...args: unknown[]) {
this.write(args.join(' '), 'info');
return this;
}
public warn(...args: unknown[]) {
this.write(args.join(' '), 'warn');
return this;
}
public error(...args: unknown[]) {
this.write(args.join(' '), 'error');
return this;
}
public debug(...args: unknown[]) {
if (process.env.DEBUG === 'zipline') return this;
this.write(args.join(' '), 'debug');
return this;
}
public trace(...args: unknown[]) {
this.write(args.join(' '), 'trace');
return this;
}
}

44
src/lib/migration.ts Normal file
View File

@@ -0,0 +1,44 @@
import { Migrate } from '@prisma/migrate/dist/Migrate';
import { ensureDatabaseExists } from '@prisma/migrate/dist/utils/ensureDatabaseExists';
import { log } from './logger';
export async function runMigrations() {
const migrate = new Migrate('./prisma/schema.prisma');
const logger = log('migrations');
logger.debug('running migrations...');
try {
logger.debug('ensuring database exists...');
const dbCreated = await ensureDatabaseExists('apply', './prisma/schema.prisma');
if (dbCreated) {
logger.info('database created');
}
} catch (e) {
logger.error('failed to create database', e);
logger.error('try creating the database manually and running the server again');
migrate.stop();
process.exit(1);
}
let migrationIds: string[];
try {
logger.debug('applying migrations...');
const { appliedMigrationNames } = await migrate.applyMigrations();
migrationIds = appliedMigrationNames;
} catch (e) {
logger.error('failed to apply migrations', e);
migrate.stop();
process.exit(1);
} finally {
migrate.stop();
}
if (migrationIds?.length === 0) {
logger.debug('no migrations applied');
return;
}
logger.info(`applied migrations: ${migrationIds.join(', ')}`);
}

58
src/server/index.ts Normal file
View File

@@ -0,0 +1,58 @@
import express from 'express';
import { join } from 'path';
import { createRequestHandler } from '@remix-run/express';
import { convertEnv } from 'src/lib/config/convert';
import { log } from 'src/lib/logger';
import { readEnv } from 'src/lib/config/read';
import { runMigrations } from 'src/lib/migration';
const MODE = process.env.NODE_ENV || 'production';
const BUILD_DIR = join(process.cwd(), 'build');
const logger = log('server');
logger.info(`starting zipline in ${MODE} mode`);
runMigrations().then(() => {});
const server = express();
const config = convertEnv(readEnv());
server.disable('x-powered-by');
server.use('/modules', express.static('public/build', { maxAge: '1y', immutable: true }));
server.use(express.static('public', { maxAge: '1h' }));
server.all(
'*',
MODE === 'production'
? createRequestHandler({ build: require(BUILD_DIR) })
: (...args) => {
purgeRequireCache();
const requestHandler = createRequestHandler({
build: require(BUILD_DIR),
mode: MODE,
getLoadContext() {
return {
config,
};
},
});
return requestHandler(...args);
}
);
server.listen(3000, () => {
require(BUILD_DIR);
logger.info(`server listening on port ${config.core.port}`);
});
function purgeRequireCache() {
for (const key in require.cache) {
if (key.startsWith(BUILD_DIR)) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete require.cache[key];
}
}
}

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2019"],
"esModuleInterop": true,
"jsx": "react-jsx",
"moduleResolution": "node",
"target": "esnext",
"strict": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"~/*": ["src/app/*"]
},
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"isolatedModules": true,
"resolveJsonModule": true,
"noEmit": true
}
}

13
tsup.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig, Options } from 'tsup';
export default defineConfig({
platform: 'node',
format: 'cjs',
treeshake: true,
clean: false,
sourcemap: true,
entryPoints: {
server: 'src/server/index.ts',
},
outDir: 'build',
});

14871
yarn.lock Normal file

File diff suppressed because it is too large Load Diff