feat: replace nextjs with preact+vite+vike

also removes the pandora dep and moves it back into this repo
also updates to new graphql-codegen presets
also adds a `textContent` field on files
also replaces headlessui with radix

fixes #36
fixes #35
This commit is contained in:
Sylver 2024-01-28 06:48:06 +08:00
parent dd597a5333
commit f4bc1d65e2
120 changed files with 9651 additions and 4223 deletions

31
.pnpmfile.cjs Normal file
View File

@ -0,0 +1,31 @@
function readPackage(pkg) {
// some dependencies will try pull in react on their own.
// normally, with vite, this is fine. vite will replace react with preact using aliases.
// but SSR mode disables those aliases, because it doesn't bundle dependencies.
// so the react imports are left in place, and then dependencies are trying to use react hooks in a preact context, and
// all other kinds of funky business.
// this replaces react with preact in dependencies, and removes react from peerDependencies.
// this means react is pretty much not installed, and pnpm aliases `react` as `preact` in node_modules
// for those packages.
// this mostly fixes all preact compatibility issues, and the ones that remain can be handled by force bundling
// the dependencies that are causing issues. you can see that in vite.config.ts, ssr.noExternal is set for some dependencies.
if (pkg.dependencies) {
if (pkg.dependencies.react) pkg.dependencies.react = 'npm:@preact/compat';
if (pkg.dependencies['react-dom']) pkg.dependencies['react-dom'] = 'npm:@preact/compat';
}
if (pkg.peerDependencies) {
delete pkg.peerDependencies.react;
delete pkg.peerDependencies['react-dom'];
}
return pkg;
}
module.exports = {
hooks: {
readPackage,
},
};

View File

@ -1,9 +1,10 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"css.validate": false,
"less.validate": false,
"npm.scriptExplorerExclude": ["^((?!watch|generate:watch).)*$"],
"scss.validate": false,
"typescript.tsdk": "node_modules/typescript/lib",
"eslint.workingDirectories": [
{
"pattern": "./{packages,apps}/*"

View File

@ -1,5 +1,4 @@
FROM node:18-alpine AS deps
ENV NEXT_TELEMETRY_DISABLED 1
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat make clang build-base python3
RUN npm i -g pnpm
@ -13,8 +12,7 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
FROM node:18-alpine AS builder
ENV NEXT_TELEMETRY_DISABLED 1
FROM node:20-alpine AS builder
WORKDIR /usr/src/micro
@ -36,8 +34,7 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
FROM node:18-alpine AS runner
ENV NEXT_TELEMETRY_DISABLED 1
FROM node:20-alpine AS runner
ENV NODE_ENV production
WORKDIR /usr/src/micro
@ -45,12 +42,8 @@ WORKDIR /usr/src/micro
RUN apk add --no-cache ffmpeg
# copy file dependencies
COPY --from=builder /usr/src/micro/packages/web/public ./packages/web/public
COPY --from=builder /usr/src/micro/packages/web/next.config.js ./packages/web/next.config.js
# copy web
COPY --from=builder --chown=node:node /usr/src/micro/packages/web/.next/standalone/ ./
COPY --from=builder --chown=node:node /usr/src/micro/packages/web/.next/static ./packages/web/.next/static/
COPY --from=builder --chown=node:node /usr/src/micro/packages/web/dist/ ./packages/web/dist/
COPY --from=builder --chown=node:node /usr/src/micro/packages/web/package.json ./packages/web/
# copy api
COPY --from=builder --chown=node:node /usr/src/micro/packages/api/pruned ./packages/api

View File

@ -21,6 +21,8 @@ A vanity file sharing service with support for ShareX. You can see a preview at
- [installation](#installation)
- [configuration](#configuration)
- [updating](#updating)
- [development](#development)
- [`web` package notes](#web-package-notes)
- [todo](#todo)
- [support](#support)
@ -97,6 +99,24 @@ The database will be automatically migrated on startup.
1. `docker compose pull micro`
2. `docker compose up -d micro`
## development
You can pull the repo and then `pnpm install`, after that everything should be good to go. You can start the `packages/api`/`packages/web` with `pnpm watch`.
### `web` package notes
> [!IMPORTANT]
> tl;dr, `web` is quirky and some packages that depend on react may break. if they do, try adding them to `noExternal` in vite.config.ts and they'll probably work.
The web package is a little weird. It uses [vike](https://vike.dev) in place of what might normally be nextjs, [preact](https://preactjs.com) in place of react and [vite](https://vitejs.dev) to build it all. Unlike nextjs, we have much more control over rendering, SSR, routing, etc. It's much faster, and much more fun. Of course, nothing is free - some hacky workarounds are required to get it working.
Preact is smaller, faster[,](https://tenor.com/view/26464591) and more fun than react. It's a drop-in replacement, but actually dropping it in is the hard part. The main problem is that in SSR mode, vite does not bundle dependencies, which means aliasing `react` to `preact` does not work because it's not transforming the packages to replace the react imports. You can force it to bundle dependencies, but then it chokes on some node dependencies like fastify. The only way I've found to get around this is to:
- Alias `react` to `preact` in node_modules using `.pnpmfile.cjs`
- Add some packages that still complain to `noExternal` in `vite.config.ts`, which forces them to be bundled and appears to resolve any remaining issues.
`tsup` is used as a final build step to bundle everything together. Vite breaks doing this, but not doing it results in much larger docker images.
## todo
- [ ] Ratelimiting

View File

@ -18,7 +18,8 @@
},
"devDependencies": {
"syncpack": "^12.3.0",
"turbo": "1.11.3"
"turbo": "1.11.3",
"typescript": "^5.3.3"
},
"packageManager": "pnpm@7.0.0"
}

View File

@ -12,6 +12,7 @@
"scripts": {
"build": "tsc --noEmit && tsup",
"lint": "eslint src --fix --cache",
"start": "node dist/main.js",
"test": "vitest run",
"watch": "tsup --watch --onSuccess \"node dist/main.js\""
},
@ -48,7 +49,8 @@
"passport-jwt": "^4.0.1",
"rxjs": "^7.8.1",
"sharp": "^0.33.1",
"stream-size": "^0.0.6"
"stream-size": "^0.0.6",
"utf-8-validate": "^6.0.3"
},
"devDependencies": {
"@atlasbot/configs": "^10.5.15",
@ -64,6 +66,7 @@
"@types/node": "^20.10.6",
"@types/nodemailer": "^6.4.14",
"@types/passport-jwt": "^4.0.0",
"@types/utf-8-validate": "^5.0.2",
"bytes": "^3.1.2",
"chalk": "^5.3.0",
"content-range": "^2.0.2",

View File

@ -0,0 +1,8 @@
const prefixes = ['image/', 'video/', 'audio/'];
export const isLikelyBinary = (mimeType: string) => {
if (mimeType === 'application/octet-stream') return true;
const hasPrefix = prefixes.some((prefix) => mimeType.startsWith(prefix));
if (hasPrefix) return true;
return false;
};

View File

@ -0,0 +1,11 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20240126092417 extends Migration {
async up(): Promise<void> {
this.addSql('alter table "files" add column "is_utf8" boolean null;');
}
async down(): Promise<void> {
this.addSql('alter table "files" drop column "is_utf8";');
}
}

View File

@ -1,7 +1,7 @@
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/postgresql';
import { UseGuards } from '@nestjs/common';
import { Args, Context, Mutation, Resolver } from '@nestjs/graphql';
import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
import type { FastifyReply } from 'fastify';
import ms from 'ms';
import { rootHost } from '../../config.js';
@ -60,7 +60,7 @@ export class AuthResolver {
return true;
}
@Mutation(() => OTPEnabledDto)
@Query(() => OTPEnabledDto)
@UseGuards(JWTAuthGuard)
async generateOTP(@UserId() userId: string) {
const user = await this.userRepo.findOneOrFail(userId);

View File

@ -40,6 +40,9 @@ export class File extends Resource {
@Field()
hash: string;
@Property({ nullable: true })
isUtf8?: boolean;
@Embedded(() => FileMetadata, { nullable: true })
@Field(() => FileMetadata, { nullable: true })
metadata?: FileMetadata;

View File

@ -8,10 +8,17 @@ import { ResourceLocations } from '../../types/resource-locations.type.js';
import { UserId } from '../auth/auth.decorators.js';
import { OptionalJWTAuthGuard } from '../auth/guards/optional-jwt.guard.js';
import { File } from './file.entity.js';
import { StorageService } from '../storage/storage.service.js';
import isValidUtf8 from 'utf-8-validate';
import { isLikelyBinary } from '../../helpers/is-likely-binary.js';
@Resolver(() => File)
export class FileResolver {
constructor(@InjectRepository(File) private readonly fileRepo: EntityRepository<File>) {}
private static readonly MAX_PREVIEWABLE_TEXT_SIZE = 1 * 1024 * 1024; // 1 MB
constructor(
@InjectRepository(File) private readonly fileRepo: EntityRepository<File>,
private storageService: StorageService,
) {}
@Query(() => File)
@UseGuards(OptionalJWTAuthGuard)
@ -27,7 +34,7 @@ export class FileResolver {
async deleteFile(
@UserId() userId: string,
@Args('fileId', { type: () => ID }) fileId: string,
@Args('key', { nullable: true }) deleteKey?: string
@Args('key', { nullable: true }) deleteKey?: string,
) {
const file = await this.fileRepo.findOneOrFail(fileId, { populate: ['deleteKey'] });
if (file.owner.id !== userId && (!deleteKey || file.deleteKey !== deleteKey)) {
@ -38,6 +45,30 @@ export class FileResolver {
return true;
}
@ResolveField(() => String, { nullable: true })
async textContent(@Parent() file: File) {
if (file.isUtf8 === false) return null;
if (file.size > FileResolver.MAX_PREVIEWABLE_TEXT_SIZE) return null;
if (isLikelyBinary(file.type)) return null;
const stream = this.storageService.createReadStream(file.hash);
const chunks = [];
for await (const chunk of stream) {
const isUtf8 = isValidUtf8(chunk);
if (!isUtf8) {
const ref = this.fileRepo.getReference(file.id);
ref.isUtf8 = false;
await this.fileRepo.persistAndFlush(ref);
return null;
}
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
return buffer.toString();
}
@ResolveField(() => String)
displayName(@Parent() file: File) {
return file.getDisplayName();

View File

@ -4,6 +4,7 @@ import { FlushMode } from '@mikro-orm/core';
import type { MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs';
import { Logger, NotFoundException } from '@nestjs/common';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { config } from './config.js';
import { FileMetadata } from './modules/file/file-metadata.embeddable.js';
import { File } from './modules/file/file.entity.js';
@ -13,9 +14,6 @@ import { Paste } from './modules/paste/paste.entity.js';
import { Thumbnail } from './modules/thumbnail/thumbnail.entity.js';
import { UserVerification } from './modules/user/user-verification.entity.js';
import { User } from './modules/user/user.entity.js';
import { fileURLToPath } from 'url';
process.env.MIKRO_ORM_DYNAMIC_IMPORTS = 'true';
export const ORM_LOGGER = new Logger('MikroORM');
export const MIGRATIONS_TABLE_NAME = 'mikro_orm_migrations';

View File

@ -58,6 +58,7 @@ type File {
paths: ResourceLocations!
size: Float!
sizeFormatted: String!
textContent: String
thumbnail: Thumbnail
type: String!
urls: ResourceLocations!
@ -108,7 +109,6 @@ type Mutation {
createUser(data: CreateUserDto!): User!
deleteFile(fileId: ID!, key: String): Boolean!
disableOTP(otpCode: String!): Boolean!
generateOTP: OTPEnabledDto!
login(otpCode: String, password: String!, username: String!): User!
logout: Boolean!
refreshToken: User!
@ -157,6 +157,7 @@ type PastePageEdge {
type Query {
config: Config!
file(fileId: ID!): File!
generateOTP: OTPEnabledDto!
invite(inviteId: ID!): Invite!
link(linkId: ID!): Link!
paste(pasteId: ID!): Paste!

View File

@ -1,7 +1,7 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/main.ts', 'src/migrations/*'],
entry: ['src/main.ts', 'src/orm.config.ts', 'src/migrations/*'],
outDir: 'dist',
target: 'node18',
format: 'esm',

View File

@ -1 +1 @@
API_URL=http://localhost:8080
PUBLIC_ENV__FRONTEND_API_URL=http://localhost:8080

View File

@ -1,17 +0,0 @@
const path = require('path');
// https://github.com/apollographql/apollo-tooling/issues/821
const isWorkspaceRoot = !__dirname.includes('packages');
const relativeSchemaPath = isWorkspaceRoot ? 'packages/api/src/schema.gql' : '../api/src/schema.gql';
const localSchemaFile = path.resolve(__dirname, relativeSchemaPath);
module.exports = {
client: {
tagName: 'gql',
excludes: ['**/generated/**'],
service: {
name: 'api',
localSchemaFile: localSchemaFile,
},
},
};

26
packages/web/codegen.ts Normal file
View File

@ -0,0 +1,26 @@
import { CodegenConfig } from '@graphql-codegen/cli';
export default {
overwrite: true,
schema: '../api/src/schema.gql',
documents: ['src/**/*.tsx'],
generates: {
'src/@generated/': {
preset: 'client',
config: {
useTypeImports: true,
},
presetConfig: {
// the rest of this update? 🤌 nectar of the gods, thank you graphql codegen gods.
// but this piece of shit? why. *why*.
fragmentMasking: false,
},
},
'src/@generated/introspection.json': {
plugins: ['introspection'],
},
},
hooks: {
afterAllFileWrite: ['prettier --write'],
},
} satisfies CodegenConfig;

View File

@ -1,12 +0,0 @@
overwrite: true
schema: '../api/src/schema.gql'
documents: 'src/**/*.graphql'
generates:
src/generated/graphql.tsx:
plugins:
- 'typescript'
- 'typescript-operations'
- 'typescript-react-apollo'
hooks:
afterAllFileWrite:
- prettier --write

View File

@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@ -1,63 +0,0 @@
const path = require('path');
module.exports = {
output: 'standalone',
experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'),
},
eslint: {
// todo: eslint is broken with typescript 5.2 and every file errors out,
// this is temporary to get it to build with eslint broken.
ignoreDuringBuilds: true,
},
async redirects() {
return [
{
// legacy compatibility, redirect old /file/:id/delete?key=x to /file/:id?deleteKey=x
// unfortunately ?key will still be included, there is apparently no way to rename the query param,
// only add a new param with the same value.
source: '/:type(f|v|i|file)/:fileId/delete',
has: [
{
type: 'query',
key: 'key',
},
],
destination: '/:type/:fileId?deleteKey=:key',
permanent: false,
},
];
},
async rewrites() {
return [
{
source: '/t/:thumbnailId',
destination: 'http://localhost:8080/thumbnail/:thumbnailId',
},
{
source: '/(f|v|i)/:fileId.:extension',
destination: 'http://localhost:8080/file/:fileId',
},
{
source: '/(p|paste)/:pasteId.:extension',
destination: 'http://localhost:8080/paste/:pasteId',
},
{
source: '/(l|s|link)/:linkId',
destination: 'http://localhost:8080/link/:linkId',
},
{
source: '/(f|v|i)/:fileId',
destination: '/file/:fileId',
},
{
source: '/p/:pasteId',
destination: '/paste/:pasteId',
},
{
source: '/api/:path*',
destination: 'http://localhost:8080/:path*',
},
];
},
};

View File

@ -4,56 +4,66 @@
"license": "GPL-3.0",
"repository": "https://github.com/sylv/micro.git",
"author": "Ryan <ryan@sylver.me>",
"type": "module",
"private": true,
"engines": {
"node": ">=20"
},
"scripts": {
"build": "NODE_ENV=production next build",
"generate": "graphql-codegen --config codegen.yml",
"lint": "NODE_ENV=production next lint",
"watch": "NODE_ENV=development concurrently \"next dev\" \"pnpm generate --watch\""
"build": "tsc --noEmit && rm -rf ./dist/* && vavite build && tsup && rm -rf ./dist/server",
"generate": "graphql-codegen --config codegen.ts",
"start": "node ./dist/index.js",
"watch": "concurrently \"vavite serve\" \"pnpm generate --watch --errors-only\""
},
"dependencies": {
"@apollo/client": "^3.8.8",
"@headlessui/react": "^1.7.17",
"@ryanke/pandora": "^0.0.9",
"devDependencies": {
"@0no-co/graphqlsp": "^1.3.0",
"@apollo/client": "^3.8.9",
"@atlasbot/configs": "^10.5.15",
"@fastify/early-hints": "^1.0.1",
"@fastify/http-proxy": "^9.3.0",
"@graphql-codegen/cli": "^5.0.0",
"@graphql-codegen/client-preset": "^4.1.0",
"@graphql-codegen/introspection": "^4.0.0",
"@graphql-typed-document-node/core": "^3.2.0",
"@parcel/watcher": "^2.3.0",
"@preact/preset-vite": "^2.8.1",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@tailwindcss/typography": "^0.5.10",
"@types/node": "^20.10.6",
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.16",
"clsx": "^2.1.0",
"concurrently": "^8.2.2",
"copy-to-clipboard": "^3.3.3",
"dayjs": "^1.11.10",
"deepmerge": "^4.3.1",
"fastify": "^4.25.2",
"formik": "^2.4.5",
"generate-avatar": "1.4.10",
"graphql": "^16.8.1",
"http-status-codes": "^2.3.0",
"lodash": "^4.17.21",
"next": "14.0.4",
"mime": "^4.0.1",
"nanoid": "^5.0.4",
"path-to-regexp": "^6.2.1",
"postcss": "^8.4.33",
"preact": "^10.19.3",
"preact-render-to-string": "^6.3.1",
"prettier": "^3.1.1",
"prism-react-renderer": "^2.3.1",
"qrcode.react": "^3.1.0",
"react": "18.2.0",
"react-dom": "^18.1.0",
"react-feather": "^2.0.9",
"react": "npm:@preact/compat@^17.1.2",
"react-dom": "npm:@preact/compat@^17.1.2",
"react-helmet-async": "^2.0.4",
"react-icons": "^5.0.1",
"react-markdown": "^9.0.1",
"readdirp": "^3.6.0",
"remark-gfm": "^4.0.0",
"swr": "^2.2.4",
"tailwindcss": "^3.4.1",
"tsup": "^8.0.1",
"typescript": "^5.3.3",
"vavite": "^4.0.1",
"vike": "^0.4.156",
"vite": "^5.0.11",
"yup": "^1.3.3"
},
"devDependencies": {
"@atlasbot/configs": "^10.5.15",
"@graphql-codegen/cli": "^5.0.0",
"@graphql-codegen/typescript": "4.0.1",
"@graphql-codegen/typescript-operations": "4.0.1",
"@graphql-codegen/typescript-react-apollo": "4.1.0",
"@parcel/watcher": "^2.3.0",
"@types/lodash": "^4.14.202",
"@types/node": "^20.10.6",
"@types/react": "^18.2.47",
"prettier": "^3.1.1",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,209 @@
/* eslint-disable */
import * as types from './graphql';
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
/**
* Map of all GraphQL operations in the project.
*
* This map has several performance disadvantages:
* 1. It is not tree-shakeable, so it will include all operations in the project.
* 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle.
* 3. It does not support dead code elimination, so it will add unused operations.
*
* Therefore it is highly recommended to use the babel or swc plugin for production.
*/
const documents = {
'\n mutation ResendVerificationEmail($data: ResendVerificationEmailDto) {\n resendVerificationEmail(data: $data)\n }\n':
types.ResendVerificationEmailDocument,
'\n fragment FileCard on File {\n id\n type\n displayName\n sizeFormatted\n thumbnail {\n width\n height\n }\n paths {\n thumbnail\n }\n urls {\n view\n }\n }\n':
types.FileCardFragmentDoc,
'\n fragment PasteCard on Paste {\n id\n title\n encrypted\n burn\n type\n createdAt\n expiresAt\n urls {\n view\n }\n }\n':
types.PasteCardFragmentDoc,
'\n query GetFiles($after: String) {\n user {\n files(first: 24, after: $after) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n node {\n id\n ...FileCard\n }\n }\n }\n }\n }\n':
types.GetFilesDocument,
'\n query GetPastes($after: String) {\n user {\n pastes(first: 24, after: $after) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n node {\n id\n ...PasteCard\n }\n }\n }\n }\n }\n':
types.GetPastesDocument,
'\n query Config {\n config {\n allowTypes\n inquiriesEmail\n requireEmails\n uploadLimit\n currentHost {\n normalised\n redirect\n }\n rootHost {\n normalised\n url\n }\n hosts {\n normalised\n }\n }\n }\n':
types.ConfigDocument,
'\n fragment RegularUser on User {\n id\n username\n email\n verifiedEmail\n }\n':
types.RegularUserFragmentDoc,
'\n query GetUser {\n user {\n ...RegularUser\n }\n }\n': types.GetUserDocument,
'\n mutation Login($username: String!, $password: String!, $otp: String) {\n login(username: $username, password: $password, otpCode: $otp) {\n ...RegularUser\n }\n }\n':
types.LoginDocument,
'\n mutation Logout {\n logout\n }\n': types.LogoutDocument,
'\n query GenerateOTP {\n generateOTP {\n recoveryCodes\n qrauthUrl\n secret\n }\n }\n':
types.GenerateOtpDocument,
'\n mutation ConfirmOTP($otpCode: String!) {\n confirmOTP(otpCode: $otpCode)\n }\n': types.ConfirmOtpDocument,
'\n mutation RefreshToken {\n refreshToken {\n ...RegularUser\n }\n }\n': types.RefreshTokenDocument,
'\n mutation DisableOTP($otpCode: String!) {\n disableOTP(otpCode: $otpCode)\n }\n': types.DisableOtpDocument,
'\n query UserQueryWithToken {\n user {\n ...RegularUser\n token\n otpEnabled\n }\n }\n':
types.UserQueryWithTokenDocument,
'\n query GetFile($fileId: ID!) {\n file(fileId: $fileId) {\n id\n type\n displayName\n size\n sizeFormatted\n textContent\n isOwner\n metadata {\n height\n width\n }\n paths {\n view\n thumbnail\n direct\n }\n urls {\n view\n }\n }\n }\n':
types.GetFileDocument,
'\n mutation DeleteFile($fileId: ID!, $deleteKey: String) {\n deleteFile(fileId: $fileId, key: $deleteKey)\n }\n':
types.DeleteFileDocument,
'\n query GetInvite($inviteId: ID!) {\n invite(inviteId: $inviteId) {\n id\n expiresAt\n }\n }\n':
types.GetInviteDocument,
'\n mutation CreateUser($user: CreateUserDto!) {\n createUser(data: $user) {\n id\n }\n }\n':
types.CreateUserDocument,
'\n mutation CreatePaste($input: CreatePasteDto!) {\n createPaste(partial: $input) {\n id\n urls {\n view\n }\n }\n }\n':
types.CreatePasteDocument,
'\n query GetPaste($pasteId: ID!) {\n paste(pasteId: $pasteId) {\n id\n title\n type\n extension\n content\n encrypted\n createdAt\n expiresAt\n burnt\n burn\n urls {\n view\n }\n }\n }\n':
types.GetPasteDocument,
'\n mutation Shorten($link: String!, $host: String) {\n createLink(destination: $link, host: $host) {\n id\n urls {\n view\n }\n }\n }\n':
types.ShortenDocument,
};
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*
*
* @example
* ```ts
* const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`);
* ```
*
* The query argument is unknown!
* Please regenerate the types.
*/
export function graphql(source: string): unknown;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n mutation ResendVerificationEmail($data: ResendVerificationEmailDto) {\n resendVerificationEmail(data: $data)\n }\n',
): (typeof documents)['\n mutation ResendVerificationEmail($data: ResendVerificationEmailDto) {\n resendVerificationEmail(data: $data)\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n fragment FileCard on File {\n id\n type\n displayName\n sizeFormatted\n thumbnail {\n width\n height\n }\n paths {\n thumbnail\n }\n urls {\n view\n }\n }\n',
): (typeof documents)['\n fragment FileCard on File {\n id\n type\n displayName\n sizeFormatted\n thumbnail {\n width\n height\n }\n paths {\n thumbnail\n }\n urls {\n view\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n fragment PasteCard on Paste {\n id\n title\n encrypted\n burn\n type\n createdAt\n expiresAt\n urls {\n view\n }\n }\n',
): (typeof documents)['\n fragment PasteCard on Paste {\n id\n title\n encrypted\n burn\n type\n createdAt\n expiresAt\n urls {\n view\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n query GetFiles($after: String) {\n user {\n files(first: 24, after: $after) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n node {\n id\n ...FileCard\n }\n }\n }\n }\n }\n',
): (typeof documents)['\n query GetFiles($after: String) {\n user {\n files(first: 24, after: $after) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n node {\n id\n ...FileCard\n }\n }\n }\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n query GetPastes($after: String) {\n user {\n pastes(first: 24, after: $after) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n node {\n id\n ...PasteCard\n }\n }\n }\n }\n }\n',
): (typeof documents)['\n query GetPastes($after: String) {\n user {\n pastes(first: 24, after: $after) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n node {\n id\n ...PasteCard\n }\n }\n }\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n query Config {\n config {\n allowTypes\n inquiriesEmail\n requireEmails\n uploadLimit\n currentHost {\n normalised\n redirect\n }\n rootHost {\n normalised\n url\n }\n hosts {\n normalised\n }\n }\n }\n',
): (typeof documents)['\n query Config {\n config {\n allowTypes\n inquiriesEmail\n requireEmails\n uploadLimit\n currentHost {\n normalised\n redirect\n }\n rootHost {\n normalised\n url\n }\n hosts {\n normalised\n }\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n fragment RegularUser on User {\n id\n username\n email\n verifiedEmail\n }\n',
): (typeof documents)['\n fragment RegularUser on User {\n id\n username\n email\n verifiedEmail\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n query GetUser {\n user {\n ...RegularUser\n }\n }\n',
): (typeof documents)['\n query GetUser {\n user {\n ...RegularUser\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n mutation Login($username: String!, $password: String!, $otp: String) {\n login(username: $username, password: $password, otpCode: $otp) {\n ...RegularUser\n }\n }\n',
): (typeof documents)['\n mutation Login($username: String!, $password: String!, $otp: String) {\n login(username: $username, password: $password, otpCode: $otp) {\n ...RegularUser\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n mutation Logout {\n logout\n }\n',
): (typeof documents)['\n mutation Logout {\n logout\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n query GenerateOTP {\n generateOTP {\n recoveryCodes\n qrauthUrl\n secret\n }\n }\n',
): (typeof documents)['\n query GenerateOTP {\n generateOTP {\n recoveryCodes\n qrauthUrl\n secret\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n mutation ConfirmOTP($otpCode: String!) {\n confirmOTP(otpCode: $otpCode)\n }\n',
): (typeof documents)['\n mutation ConfirmOTP($otpCode: String!) {\n confirmOTP(otpCode: $otpCode)\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n mutation RefreshToken {\n refreshToken {\n ...RegularUser\n }\n }\n',
): (typeof documents)['\n mutation RefreshToken {\n refreshToken {\n ...RegularUser\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n mutation DisableOTP($otpCode: String!) {\n disableOTP(otpCode: $otpCode)\n }\n',
): (typeof documents)['\n mutation DisableOTP($otpCode: String!) {\n disableOTP(otpCode: $otpCode)\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n query UserQueryWithToken {\n user {\n ...RegularUser\n token\n otpEnabled\n }\n }\n',
): (typeof documents)['\n query UserQueryWithToken {\n user {\n ...RegularUser\n token\n otpEnabled\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n query GetFile($fileId: ID!) {\n file(fileId: $fileId) {\n id\n type\n displayName\n size\n sizeFormatted\n textContent\n isOwner\n metadata {\n height\n width\n }\n paths {\n view\n thumbnail\n direct\n }\n urls {\n view\n }\n }\n }\n',
): (typeof documents)['\n query GetFile($fileId: ID!) {\n file(fileId: $fileId) {\n id\n type\n displayName\n size\n sizeFormatted\n textContent\n isOwner\n metadata {\n height\n width\n }\n paths {\n view\n thumbnail\n direct\n }\n urls {\n view\n }\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n mutation DeleteFile($fileId: ID!, $deleteKey: String) {\n deleteFile(fileId: $fileId, key: $deleteKey)\n }\n',
): (typeof documents)['\n mutation DeleteFile($fileId: ID!, $deleteKey: String) {\n deleteFile(fileId: $fileId, key: $deleteKey)\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n query GetInvite($inviteId: ID!) {\n invite(inviteId: $inviteId) {\n id\n expiresAt\n }\n }\n',
): (typeof documents)['\n query GetInvite($inviteId: ID!) {\n invite(inviteId: $inviteId) {\n id\n expiresAt\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n mutation CreateUser($user: CreateUserDto!) {\n createUser(data: $user) {\n id\n }\n }\n',
): (typeof documents)['\n mutation CreateUser($user: CreateUserDto!) {\n createUser(data: $user) {\n id\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n mutation CreatePaste($input: CreatePasteDto!) {\n createPaste(partial: $input) {\n id\n urls {\n view\n }\n }\n }\n',
): (typeof documents)['\n mutation CreatePaste($input: CreatePasteDto!) {\n createPaste(partial: $input) {\n id\n urls {\n view\n }\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n query GetPaste($pasteId: ID!) {\n paste(pasteId: $pasteId) {\n id\n title\n type\n extension\n content\n encrypted\n createdAt\n expiresAt\n burnt\n burn\n urls {\n view\n }\n }\n }\n',
): (typeof documents)['\n query GetPaste($pasteId: ID!) {\n paste(pasteId: $pasteId) {\n id\n title\n type\n extension\n content\n encrypted\n createdAt\n expiresAt\n burnt\n burn\n urls {\n view\n }\n }\n }\n'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n mutation Shorten($link: String!, $host: String) {\n createLink(destination: $link, host: $host) {\n id\n urls {\n view\n }\n }\n }\n',
): (typeof documents)['\n mutation Shorten($link: String!, $host: String) {\n createLink(destination: $link, host: $host) {\n id\n urls {\n view\n }\n }\n }\n'];
export function graphql(source: string) {
return (documents as any)[source] ?? {};
}
export type DocumentType<TDocumentNode extends DocumentNode<any, any>> =
TDocumentNode extends DocumentNode<infer TType, any> ? TType : never;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
export * from './gql';

File diff suppressed because it is too large Load Diff

View File

@ -1,110 +0,0 @@
import type { NormalizedCacheObject } from '@apollo/client';
import { ApolloClient, from, HttpLink, InMemoryCache } from '@apollo/client';
import { onError } from '@apollo/client/link/error';
import { relayStylePagination } from '@apollo/client/utilities';
import merge from 'deepmerge';
import isEqual from 'lodash/isEqual';
import type { GetServerSidePropsContext } from 'next';
import { useMemo } from 'react';
import { apiUri, isServer } from './helpers/http.helper';
export const APOLLO_STATE_PROP_NAME = '__APOLLO_STATE__';
let globalClient: ApolloClient<NormalizedCacheObject> | undefined;
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
for (const { message, locations, path } of graphQLErrors) {
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`);
}
}
if (networkError) {
console.log(`[Network error]: ${networkError.message}`);
}
});
function createApolloClient(context?: GetServerSidePropsContext) {
const httpLink = new HttpLink({
uri: apiUri + '/graphql',
credentials: 'same-origin',
fetch: (url, init) => {
if (!context) return fetch(url, init);
return fetch(url, {
...init,
headers: {
...init?.headers,
cookie: context.req.headers.cookie as string,
},
});
},
});
return new ApolloClient({
ssrMode: typeof window === 'undefined',
link: from([errorLink, httpLink]),
cache: new InMemoryCache({
typePolicies: {
User: {
keyFields: [],
fields: {
files: relayStylePagination(),
pastes: relayStylePagination(),
},
},
Config: {
keyFields: [],
},
},
}),
});
}
export function initializeApollo(options?: { initialState?: any; context?: GetServerSidePropsContext }) {
const client = globalClient ?? createApolloClient(options?.context);
if (options?.initialState) {
const existingCache = client.extract();
// Merge the initialState from getStaticProps/getServerSideProps in the existing cache
const data = merge(existingCache, options.initialState, {
// combine arrays using object equality (like in sets)
arrayMerge: (destinationArray, sourceArray) => [
...sourceArray,
...destinationArray.filter((d) => sourceArray.every((s) => !isEqual(d, s))),
],
});
client.cache.restore(data);
}
// ensure we never use the same client server-side
// so we're not leaking sessions between requests
if (isServer) return client;
if (!globalClient) {
globalClient = client;
}
return client;
}
export function addStateToPageProps(client: ApolloClient<NormalizedCacheObject>, pageProps: any) {
if (pageProps?.props) {
pageProps.props[APOLLO_STATE_PROP_NAME] = client.cache.extract();
}
return pageProps;
}
export function useApollo(pageProps: any) {
const state = pageProps[APOLLO_STATE_PROP_NAME];
return useMemo(() => {
return initializeApollo({ initialState: state });
}, [state]);
}
// here in case future me adds persistent caching and it has to be handled or something
export const resetClient = () => {
// client.resetStore() does not seem to work, so instead of fighting with apollo over it not clearing hook data,
// we just reload the entire page which ensures any cache it has is nuked from orbit.
window.location.href = '/';
};

24
packages/web/src/app.tsx Normal file
View File

@ -0,0 +1,24 @@
import React, { FC, Fragment } from 'react';
import { Header } from './components/header/header';
import { Title } from './components/title';
import './styles/globals.css';
import { ToastProvider } from './components/toast';
import { Helmet } from 'react-helmet-async';
interface AppProps {
children: React.ReactNode;
}
export const App: FC<AppProps> = ({ children }) => (
<Fragment>
<Title>Home</Title>
<Helmet>
<meta property="og:site_name" content="micro" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</Helmet>
<ToastProvider>
<Header />
<div className="py-4 md:py-16">{children}</div>
</ToastProvider>
</Fragment>
);

View File

@ -2,7 +2,7 @@
import clsx from 'clsx';
import * as avatar from 'generate-avatar';
import type { FC } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { useMemo, useRef } from 'react';
export interface AvatarProps {
userId: string;
@ -13,15 +13,9 @@ export const Avatar: FC<AvatarProps> = (props) => {
const classes = clsx('overflow-hidden rounded-full select-none', props.className);
const containerRef = useRef<HTMLDivElement>(null);
const svg = useMemo(() => {
return avatar.generateFromString(props.userId);
const result = avatar.generateFromString(props.userId);
return result.replace(/(width|height)="(\d+)"/g, '$1="100%"');
}, [props.userId]);
useEffect(() => {
if (containerRef.current) {
containerRef.current.firstElementChild?.setAttribute('height', 'inherit');
containerRef.current.firstElementChild?.setAttribute('width', 'inherit');
}
}, [containerRef]);
return <div className={classes} dangerouslySetInnerHTML={{ __html: svg }} ref={containerRef} />;
};

View File

@ -0,0 +1,18 @@
import clsx from 'clsx';
import React, { forwardRef } from 'react';
import { FiArrowLeft } from 'react-icons/fi';
export interface BreadcrumbsProps {
href: string;
children: string;
className?: string;
}
export const Breadcrumbs = forwardRef<HTMLAnchorElement, BreadcrumbsProps>(({ href, children, className }, ref) => {
const classes = clsx('text-sm text-gray-500 flex items-center gap-1 hover:underline', className);
return (
<a href={href} className={classes} ref={ref}>
<FiArrowLeft className="h-4 w-4" /> {children}
</a>
);
});

View File

@ -0,0 +1,62 @@
/* eslint-disable react/button-has-type */
import clsx from 'clsx';
import type { FC, HTMLAttributes } from 'react';
import React, { forwardRef } from 'react';
import { Spinner } from './spinner';
export interface ButtonProps extends Omit<HTMLAttributes<HTMLButtonElement | HTMLAnchorElement>, 'prefix' | 'style'> {
href?: string;
disabled?: boolean;
style?: ButtonStyle;
loading?: boolean;
type?: 'submit' | 'reset' | 'button';
as?: FC | 'button' | 'a';
}
export enum ButtonStyle {
Primary = 'bg-purple-500 hover:bg-purple-400',
Secondary = 'bg-dark-600 hover:bg-dark-900',
Disabled = 'bg-dark-300 hover:bg-dark-400 cursor-not-allowed',
}
export const Button = forwardRef<any, ButtonProps>(
(
{
as: As = 'button',
disabled,
className,
type,
children,
loading,
style = ButtonStyle.Primary,
onClick,
onKeyDown,
...rest
},
ref,
) => {
if (disabled) style = ButtonStyle.Disabled;
const onClickWrap = disabled || loading ? undefined : onClick;
const onKeyDownWrap = disabled || loading ? undefined : onKeyDown;
const classes = clsx(
'flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium transition rounded truncate max-h-[2.65em]',
className,
style,
);
return (
<As
type={type}
className={classes}
disabled={disabled}
onClick={onClickWrap}
onKeyDown={onKeyDownWrap}
style={{ height: '2.5rem' }}
{...rest}
ref={ref}
>
{children} {loading && <Spinner size="small" />}
</As>
);
},
);

View File

@ -0,0 +1,11 @@
import clsx from 'clsx';
import type { FC, HTMLAttributes } from 'react';
export const Card: FC<HTMLAttributes<HTMLDivElement>> = ({ className, children, ...rest }) => {
const classes = clsx(className, 'p-4 bg-dark-200 rounded');
return (
<div className={classes} {...rest}>
{children}
</div>
);
};

View File

@ -0,0 +1,31 @@
import clsx from 'clsx';
import type { FC, ReactNode } from 'react';
import React from 'react';
export interface ContainerProps {
centerX?: boolean;
centerY?: boolean;
center?: boolean;
small?: boolean;
className?: string;
children: ReactNode;
}
export const Container: FC<ContainerProps> = ({
center,
centerX = center,
centerY = center,
className,
small,
children,
}) => {
const classes = clsx(className, 'px-4 mx-auto', {
'sm:max-w-screen-sm md:max-w-screen-md lg:max-w-screen-lg xl:max-w-screen-xl': !small,
'flex justify-center flex-col': centerX || centerY,
'absolute top-16 bottom-0 right-0 left-0': centerY,
'items-center': centerX,
'max-w-xs': small,
});
return <div className={classes}>{children}</div>;
};

View File

@ -0,0 +1,51 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import clsx from 'clsx';
import type { FC, ReactNode } from 'react';
import { Link } from './link';
export interface DropdownProps {
trigger: ReactNode;
children: ReactNode;
className?: string;
}
export const Dropdown: FC<DropdownProps> = ({ trigger, children, className }) => {
const itemsClasses = clsx(
'absolute right-0 mt-2 overflow-y-auto rounded-md shadow-2xl bg-dark-300 focus:outline-none max-h-56 min-w-[10em]',
className,
);
return (
<DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger asChild>{trigger}</DropdownMenu.Trigger>
<DropdownMenu.Content className={itemsClasses}>{children}</DropdownMenu.Content>
</DropdownMenu.Root>
);
};
export interface DropdownTabProps {
href?: string;
className?: string;
children: ReactNode;
onClick?: () => void;
}
export const DropdownTab: FC<DropdownTabProps> = ({ href, className, children, onClick }) => {
const As = href ? Link : onClick ? 'button' : 'div';
const base = clsx(
'block w-full text-left px-3 py-2 my-1 text-gray-400 transition ease-in-out border-none cursor-pointer hover:bg-dark-800',
className,
);
return (
<DropdownMenu.Item className="outline-none" asChild={!href}>
<As href={href!} className={base} onClick={onClick}>
{children}
</As>
</DropdownMenu.Item>
);
};
export const DropdownDivider: FC = () => {
return <hr className="w-full !border-none bg-dark-900 h-px" />;
};

View File

@ -1,5 +0,0 @@
import type { FC } from 'react';
export const DropdownDivider: FC = () => {
return <hr className="w-full !border-none bg-dark-900 h-px" />;
};

View File

@ -1,29 +0,0 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { Menu } from '@headlessui/react';
import clsx from 'clsx';
import type { FC, ReactNode } from 'react';
import { Fragment } from 'react';
import { Link } from '../link';
export interface DropdownTabProps {
href?: string;
className?: string;
children: ReactNode;
onClick?: () => void;
}
export const DropdownTab: FC<DropdownTabProps> = ({ href, className, children, onClick }) => {
const props = href ? { as: Link, href: href } : { as: Fragment };
const base = clsx('px-3 py-2 my-1 text-gray-400 transition ease-in-out border-none cursor-pointer', className);
return (
<Menu.Item {...(props as any)}>
{({ active }) => (
<div className={clsx(base, active && 'text-white bg-dark-800')} onClick={onClick}>
{children}
</div>
)}
</Menu.Item>
);
};

View File

@ -1,40 +0,0 @@
import { Menu, Transition } from '@headlessui/react';
import clsx from 'clsx';
import type { FC, ReactNode } from 'react';
import React, { Fragment } from 'react';
export interface DropdownProps {
trigger: ReactNode;
children: ReactNode;
className?: string;
}
export const Dropdown: FC<DropdownProps> = ({ trigger, children, className }) => {
const itemsClasses = clsx(
'absolute right-0 mt-2 overflow-y-auto rounded-md shadow-2xl bg-dark-300 focus:outline-none max-h-56 min-w-[10em]',
className
);
return (
<Menu as="div" className="relative z-10">
{({ open }) => (
<Fragment>
<Menu.Button as={Fragment}>{trigger}</Menu.Button>
<Transition
show={open}
enter="ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items className={itemsClasses} static>
{children}
</Menu.Items>
</Transition>
</Fragment>
)}
</Menu>
);
};

View File

@ -1,17 +1,17 @@
import Head from 'next/head';
import type { FC, ReactNode } from 'react';
import { Fragment } from 'react';
import type { Embeddable } from './embeddable';
import { Helmet } from 'react-helmet-async';
export const EmbedContainer: FC<{ data: Embeddable; children: ReactNode }> = ({ data, children }) => {
return (
<Fragment>
<Head>
<Helmet>
<meta name="twitter:title" content={data.displayName} />
<meta property="og:title" content={data.displayName} key="title" />
<meta property="og:url" content={data.paths.view} />
<meta property="og:type" content="article" />
</Head>
</Helmet>
{children}
</Fragment>
);

View File

@ -4,7 +4,7 @@ export interface Embeddable {
displayName?: string;
height?: number | null;
width?: number | null;
content?: { data?: string | null; error?: any };
textContent?: string | null;
paths: {
direct: string;
view?: string;

View File

@ -1,6 +1,6 @@
import clsx from 'clsx';
import Head from 'next/head';
import { Fragment } from 'react';
import { Helmet } from 'react-helmet-async';
import { BASE_EMBED_CLASSES, MAX_HEIGHT } from '../embed';
import type { Embeddable } from '../embeddable';
@ -9,15 +9,15 @@ export const EmbedImage = ({ data }: { data: Embeddable }) => {
const containerClasses = clsx(
'flex items-center justify-center relative overflow-hidden',
BASE_EMBED_CLASSES,
MAX_HEIGHT
MAX_HEIGHT,
);
return (
<Fragment>
<Head>
<Helmet>
<meta name="twitter:image" content={data.paths.direct} />
<meta property="og:image" content={data.paths.direct} />
</Head>
</Helmet>
<div className={containerClasses}>
<img
className={imageClasses}

View File

@ -1,31 +1,22 @@
import useSWR from 'swr';
import { Markdown } from '../../markdown';
import { PageLoader } from '../../page-loader';
import { EmbedDefault } from './embed-default';
import type { Embeddable } from '../embeddable';
import { textFetcher } from '../text-fetcher';
import clsx from 'clsx';
import { Markdown } from '../../markdown';
import { BASE_EMBED_CLASSES } from '../embed';
import type { Embeddable } from '../embeddable';
export const EmbedMarkdown = ({ data }: { data: Embeddable }) => {
const swrContent = useSWR<string>(data.content ? null : data.paths.direct, { fetcher: textFetcher });
const content = data.content ?? swrContent;
const classes = clsx('p-4', BASE_EMBED_CLASSES);
if (content.error) {
return <EmbedDefault data={data} />;
if (!data.textContent) {
throw new Error('EmbedText requires textContent');
}
if (!content.data) {
return <PageLoader />;
}
return <Markdown className={classes}>{content.data}</Markdown>;
return <Markdown className={classes}>{data.textContent}</Markdown>;
};
const MAX_MARKDOWN_SIZE = 1_000_000; // 1mb
EmbedMarkdown.embeddable = (data: Embeddable) => {
if (data.size > MAX_MARKDOWN_SIZE) return false;
if (!data.textContent) return false;
if (data.type === 'text/markdown') return true;
if (data.type === 'text/plain' && data.displayName?.endsWith('md')) return true;
return false;

View File

@ -1,46 +1,34 @@
import type { Language } from 'prism-react-renderer';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { getFileLanguage } from '../../../helpers/get-file-language.helper';
import { PageLoader } from '../../page-loader';
import { SyntaxHighlighter } from '../../syntax-highlighter/syntax-highlighter';
import { BASE_EMBED_CLASSES } from '../embed';
import type { Embeddable } from '../embeddable';
import { textFetcher } from '../text-fetcher';
import { EmbedDefault } from './embed-default';
const DEFAULT_LANGUAGE = getFileLanguage('diff')!;
const MAX_SIZE = 1_000_000; // 1mb
export const EmbedText = ({ data }: { data: Embeddable }) => {
const [language, setLanguage] = useState(getFileLanguage(data.displayName) ?? DEFAULT_LANGUAGE);
const swrContent = useSWR<string>(data.content ? null : data.paths.direct, { fetcher: textFetcher });
const content = data.content ?? swrContent;
useEffect(() => {
// re-calculate language on fileName change
setLanguage(getFileLanguage(data.displayName) ?? DEFAULT_LANGUAGE);
}, [data.displayName]);
if (content.error) {
return <EmbedDefault data={data} />;
}
if (!content.data) {
return <PageLoader />;
if (!data.textContent) {
throw new Error('EmbedText requires textContent');
}
return (
<SyntaxHighlighter language={language.key as Language} className={BASE_EMBED_CLASSES}>
{content.data}
{data.textContent}
</SyntaxHighlighter>
);
};
EmbedText.embeddable = (data: Embeddable) => {
if (data.type.startsWith('text/')) return true;
if (data.type === 'application/json') return true;
if (getFileLanguage(data.displayName)) return true;
if (data.size > MAX_SIZE) return false;
if (data.textContent) return true;
return false;
};

View File

@ -1,8 +1,9 @@
import { Container } from '@ryanke/pandora';
import { Link } from '../components/link';
import { Title } from '../components/title';
import { FC } from 'react';
import { getErrorMessage } from '../helpers/get-error-message.helper';
import { usePaths } from '../hooks/usePaths';
import { Container } from './container';
import { Link } from './link';
import { Title } from './title';
export enum Lenny {
Concerned = 'ಠ_ಠ',
@ -12,11 +13,12 @@ export enum Lenny {
Wut = 'ლ,ᔑ•ﺪ͟͠•ᔐ.ლ',
Happy = '(◉͜ʖ◉)',
Shrug = '¯\\_(⊙_ʖ⊙)_/¯',
Angry = "(ง'̀-'́)ง",
}
export type ErrorProps = ({ error: unknown } | { message: string }) & { lenny?: Lenny };
export default function Error(props: ErrorProps) {
export const Error: FC<ErrorProps> = (props) => {
const message = 'message' in props ? props.message : getErrorMessage(props.error) || 'An unknown error occurred.';
const paths = usePaths();
const lenny = props.lenny ?? Lenny.Wut;
@ -31,4 +33,4 @@ export default function Error(props: ErrorProps) {
</Link>
</Container>
);
}
};

View File

@ -2,11 +2,9 @@ import type { FC } from 'react';
import { Fragment } from 'react';
import { usePaths } from '../../hooks/usePaths';
import { useUser } from '../../hooks/useUser';
import { Dropdown } from '../dropdown/dropdown';
import { DropdownDivider } from '../dropdown/dropdown-divider';
import { DropdownTab } from '../dropdown/dropdown-tab';
import { Link } from '../link';
import { UserPill } from '../user-pill';
import { Dropdown, DropdownDivider, DropdownTab } from '../dropdown';
export interface HeaderUserProps {
username: string;

View File

@ -1,3 +0,0 @@
mutation ResendVerificationEmail($data: ResendVerificationEmailDto) {
resendVerificationEmail(data: $data)
}

View File

@ -1,14 +1,25 @@
import { Button, ButtonStyle, Container, useAsync, useOnClickOutside, useToasts } from '@ryanke/pandora';
import clsx from 'clsx';
import { Fragment, memo, useRef, useState } from 'react';
import { Crop } from 'react-feather';
import { useResendVerificationEmailMutation } from '../../generated/graphql';
import { FiCrop } from 'react-icons/fi';
import { useAsync } from '../../hooks/useAsync';
import { useConfig } from '../../hooks/useConfig';
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
import { usePaths } from '../../hooks/usePaths';
import { useUser } from '../../hooks/useUser';
import { Button, ButtonStyle } from '../button';
import { Container } from '../container';
import { Input } from '../input/input';
import { Link } from '../link';
import { useToasts } from '../toast';
import { HeaderUser } from './header-user';
import { graphql } from '../../@generated';
import { useMutation } from '@apollo/client';
const ResendVerificationEmail = graphql(`
mutation ResendVerificationEmail($data: ResendVerificationEmailDto) {
resendVerificationEmail(data: $data)
}
`);
export const Header = memo(() => {
const user = useUser();
@ -21,7 +32,7 @@ export const Header = memo(() => {
const [resent, setResent] = useState(false);
const classes = clsx(
'relative z-20 flex items-center justify-between h-16 my-auto transition',
paths.loading && 'pointer-events-none invisible'
paths.loading && 'pointer-events-none invisible',
);
useOnClickOutside(emailInputRef, () => {
@ -29,7 +40,7 @@ export const Header = memo(() => {
setShowEmailInput(false);
});
const [resendMutation] = useResendVerificationEmailMutation();
const [resendMutation] = useMutation(ResendVerificationEmail);
const [resendVerification, sendingVerification] = useAsync(async () => {
if (resent || !user.data) return;
if (!user.data.email && !email) {
@ -107,7 +118,7 @@ export const Header = memo(() => {
<nav className={classes}>
<div className="flex items-center">
<Link href={paths.home} className="flex">
<Crop className="mr-2 text-primary" /> micro
<FiCrop className="w-[24px] h-[24px] mr-2 text-primary" /> micro
</Link>
</div>
<div className="flex items-center">

View File

@ -1,6 +1,6 @@
import { Spinner } from '@ryanke/pandora';
import type { FC } from 'react';
import { Input } from './input';
import { Spinner } from '../spinner';
export interface OtpInputProps {
loading: boolean;

View File

@ -1,9 +1,9 @@
import clsx from 'clsx';
import type { SelectHTMLAttributes } from 'react';
import React from 'react';
import { ChevronDown } from 'react-feather';
import type { InputChildProps } from './container';
import { InputContainer } from './container';
import { FiChevronDown } from 'react-icons/fi';
export interface SelectProps extends InputChildProps<SelectHTMLAttributes<HTMLSelectElement>> {}
@ -18,7 +18,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(({ classN
{children}
</select>
<div className="absolute right-0 top-0 bottom-0 flex items-center justify-center w-10 text-gray-500 pointer-events-none">
<ChevronDown size="1em" className="stroke-current" />
<FiChevronDown size="1em" className="stroke-current" />
</div>
</div>
);

View File

@ -1,7 +1,6 @@
import type { ButtonProps } from '@ryanke/pandora';
import { Button } from '@ryanke/pandora';
import { useFormikContext } from 'formik';
import type { FC } from 'react';
import { Button, ButtonProps } from '../button';
/**
* Wraps a button and disables when the form is not ready to be submitted.

View File

@ -1,10 +1,13 @@
import NextLink from 'next/link';
import type { FC, HTMLAttributes } from 'react';
import { forwardRef, Fragment, type HTMLAttributes } from 'react';
export interface LinkProps extends HTMLAttributes<HTMLAnchorElement> {
href: string;
}
export const Link: FC<LinkProps> = ({ children, ...rest }) => {
return <NextLink {...rest}>{children}</NextLink>;
};
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(({ children, ...rest }, ref) => {
return (
<a {...rest} ref={ref}>
{children}
</a>
);
});

View File

@ -1,7 +1,8 @@
import { Container, Spinner } from '@ryanke/pandora';
import type { FC } from 'react';
import { useEffect } from 'react';
import { Title } from './title';
import { Container } from './container';
import { Spinner } from './spinner';
export const PageLoader: FC<{ title?: string }> = ({ title }) => {
useEffect(() => {

View File

@ -0,0 +1,26 @@
import clsx from 'clsx';
import type { FC, HTMLAttributes } from 'react';
import React from 'react';
export interface SpinnerProps extends HTMLAttributes<SVGElement> {
size?: 'small' | 'medium' | 'large';
}
export const Spinner: FC<SpinnerProps> = ({ size, className, ...rest }) => {
const classes = clsx('animate-spin', className, {
'w-4': size === 'small',
'w-6': !size || size === 'medium',
'w-9': size === 'large',
});
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" {...rest} className={classes}>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
};

View File

@ -1,9 +1,9 @@
import { Menu } from '@headlessui/react';
import type { Language } from 'prism-react-renderer';
import { memo } from 'react';
import { ChevronDown } from 'react-feather';
import { FiChevronDown } from 'react-icons/fi';
import languages from '../../data/languages.json';
import { useToasts } from '@ryanke/pandora';
import { useToasts } from '../toast';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
export interface SyntaxHighlighterControlsProps {
onLanguageChange: (language: Language) => void;
@ -21,25 +21,24 @@ export const SyntaxHighlighterControls = memo<SyntaxHighlighterControlsProps>(
return (
<div className="absolute right-0 top-0 flex items-center gap-2 z-10 bg-black bg-opacity-75 hover:bg-opacity-100 rounded-bl-lg pl-2 pb-2">
<Menu as="div" className="relative">
<Menu.Button className="text-xs flex items-center gap-1 text-gray-500 hover:text-white transition pt-2 ">
{language} <ChevronDown className="h-4 w-4" />
</Menu.Button>
<Menu.Items className="absolute top-full mt-3 bg-gray-900 text-sm max-h-44 overflow-y-scroll text-xs right-0 rounded">
<DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger className="text-xs flex items-center gap-1 text-gray-500 hover:text-white transition pt-2 outline-none">
{language} <FiChevronDown className="h-4 w-4" />
</DropdownMenu.Trigger>
<DropdownMenu.Content className="absolute top-full mt-3 bg-gray-900 text-sm max-h-44 overflow-y-scroll text-xs right-0 rounded">
{languages.map((language) => (
<Menu.Item
as="div"
<DropdownMenu.Item
key={language.name}
className="text-gray-400 hover:text-white transition cursor-pointer truncate px-3 py-1 hover:bg-gray-800"
className="text-gray-400 hover:text-white transition cursor-pointer truncate px-3 py-1 hover:bg-gray-800 outline-none"
onClick={() => {
onLanguageChange(language.key as Language);
}}
>
{language.name}
</Menu.Item>
</DropdownMenu.Item>
))}
</Menu.Items>
</Menu>
</DropdownMenu.Content>
</DropdownMenu.Root>
<button
type="button"
className="text-xs text-gray-500 hover:text-white transition pr-3 pt-2"
@ -49,5 +48,5 @@ export const SyntaxHighlighterControls = memo<SyntaxHighlighterControlsProps>(
</button>
</div>
);
}
},
);

View File

@ -19,8 +19,7 @@ export const SyntaxHighlighter = memo<SyntaxHighlighterProps>(
return (
<Highlight theme={theme} code={trimmed} language={language}>
{({ className: highlighterClasses, style, tokens, getLineProps, getTokenProps }) => {
console.log(highlighterClasses);
{({ className: highlighterClasses, tokens, getLineProps, getTokenProps }) => {
const containerClasses = clsx(
'text-left overflow-x-auto h-full relative',
highlighterClasses,

View File

@ -1,12 +1,12 @@
import Head from 'next/head';
import type { FC } from 'react';
import { Helmet } from 'react-helmet-async';
export const Title: FC<{ children: string | string[] }> = ({ children }) => {
const title = Array.isArray(children) ? children.join(' ') : children;
return (
<Head>
<Helmet>
<title>{`${title} — micro`}</title>
<meta property="og:title" content={title} key="title" />
</Head>
</Helmet>
);
};

View File

@ -0,0 +1,5 @@
import React from "react";
import { ToastProps } from "./toast";
export type ToastContextData = null | ((toast: ToastProps) => void);
export const ToastContext = React.createContext<ToastContextData>(null);

View File

@ -0,0 +1,3 @@
export * from "./context";
export * from "./toast-provider";
export * from "./useToasts";

View File

@ -0,0 +1,58 @@
import { nanoid } from 'nanoid';
import type { FC, ReactNode } from 'react';
import React, { Fragment, useCallback, useState } from 'react';
import { ToastContext } from './context';
import type { ToastProps } from './toast';
import { Toast, TRANSITION_DURATION } from './toast';
// spread operators on arrays are to fix this
// https://stackoverflow.com/questions/56266575/why-is-usestate-not-triggering-re-render
export const ToastProvider: FC<{ children: ReactNode }> = (props) => {
const [toasts, setToasts] = useState<(ToastProps & { id: string; timer: NodeJS.Timeout })[]>([]);
const createToast = useCallback(
(toast: ToastProps) => {
if (toasts.some((existing) => existing.text === toast.text)) {
// skip duplicate cards
return;
}
const timeout = toast.timeout ?? 5000;
const id = nanoid();
const timer = setTimeout(() => {
// set removing on toast starting the transition
setToasts((toasts) => {
for (const toast of toasts) {
if (toast.id !== id) continue;
toast.removing = true;
}
return [...toasts];
});
setTimeout(() => {
// remove toast once transition is complete
setToasts((toasts) => toasts.filter((toast) => toast.id !== id));
}, TRANSITION_DURATION);
}, timeout - TRANSITION_DURATION);
// create toast
setToasts((toasts) => {
const data = Object.assign(toast, { id, timer });
return [...toasts, data];
});
},
[toasts, setToasts],
);
return (
<ToastContext.Provider value={createToast}>
{props.children}
<div className="fixed flex justify-end bottom-5 right-5 left-5">
{toasts.map((toast) => (
<Toast key={toast.id} removing={toast.removing} {...toast} />
))}
</div>
</ToastContext.Provider>
);
};

View File

@ -0,0 +1,35 @@
import clsx from 'clsx';
import type { FC } from 'react';
import React, { useEffect, useState } from 'react';
export interface ToastProps {
text: string;
error?: boolean;
timeout?: number;
removing?: boolean;
}
export const TRANSITION_DURATION = 300;
export const Toast: FC<ToastProps> = (props) => {
const initialClasses = 'opacity-0 scale-90';
const animateClasses = 'opacity-100 translate-x-0';
const [transition, setTransition] = useState(initialClasses);
const classes = clsx('p-4 transition duration-300 rounded shadow-xl select-none w-96', transition, {
'bg-violet-500': !props.error,
'bg-red-600': props.error,
});
useEffect(() => {
if (props.removing) setTransition(initialClasses);
else {
// breaks the browser trying to optimise the transition by skipping it because we add it so fast
requestAnimationFrame(() => {
setTimeout(() => {
setTransition(animateClasses);
});
});
}
}, [props.removing]);
return <div className={classes}>{props.text}</div>;
};

View File

@ -0,0 +1,13 @@
import { useContext } from "react";
import { ToastContext } from "./context";
export const useToasts = () => {
const createToast = useContext(ToastContext);
if (!createToast) {
// todo: this should be an error, but it seems like it can be undefined.
// maybe due to concurrent rendering? idk shit about fuck.
return () => undefined;
}
return createToast;
};

View File

@ -12,7 +12,7 @@ export const UserPill = forwardRef<HTMLDivElement, UserPillProps>(
({ userId: id, username, className, ...rest }, ref) => {
const classes = clsx(
'flex items-center px-2 py-1 transition rounded-full shadow-lg cursor-pointer select-none align-center bg-dark-600 hover:bg-dark-900 hover:text-white',
className
className,
);
return (
@ -21,5 +21,5 @@ export const UserPill = forwardRef<HTMLDivElement, UserPillProps>(
<Avatar userId={id} className="w-6 h-6" />
</div>
);
}
},
);

View File

@ -1,15 +1,15 @@
import clsx from 'clsx';
import { type FC, type ReactNode } from 'react';
import { Info } from 'react-feather';
import { FiInfo } from 'react-icons/fi';
export const Warning: FC<{ children: ReactNode; className?: string }> = ({ children, className }) => {
const classes = clsx(
'bg-purple-400 bg-opacity-40 border border-purple-400 px-2 py-1 rounded text-sm flex items-center gap-2',
className
className,
);
return (
<div className={classes} role="alert">
<Info className="text-purple-400 h-5 w-5" />
<FiInfo className="text-purple-400 h-5 w-5" />
{children}
</div>
);

View File

@ -1,17 +1,21 @@
import { Container, Spinner } from '@ryanke/pandora';
import clsx from 'clsx';
import { Fragment, useState } from 'react';
import { Download } from 'react-feather';
import { FC, Fragment, useState } from 'react';
import { FiDownload } from 'react-icons/fi';
import { RegularUserFragment } from '../../@generated/graphql';
import { Container } from '../../components/container';
import { Section } from '../../components/section';
import { Spinner } from '../../components/spinner';
import { Toggle } from '../../components/toggle';
import { downloadFile } from '../../helpers/download.helper';
import { generateConfig } from '../../helpers/generate-config.helper';
import { useConfig } from '../../hooks/useConfig';
import { useUser } from '../../hooks/useUser';
import { CustomisationOption } from './customisation-option';
export const ConfigGenerator = () => {
const user = useUser();
export interface ConfigGeneratorProps {
user: RegularUserFragment & { token: string };
}
export const ConfigGenerator: FC<ConfigGeneratorProps> = ({ user }) => {
const [selectedHosts, setSelectedHosts] = useState<string[]>([]);
const [embedded, setEmbedded] = useState(true);
const [pasteShortcut, setPasteShortcut] = useState(true);
@ -19,15 +23,15 @@ export const ConfigGenerator = () => {
const downloadable = !!selectedHosts[0];
const download = () => {
if (!downloadable || !user.data) return;
if (!downloadable) return;
const { name, content } = generateConfig({
direct: !embedded,
hosts: selectedHosts,
shortcut: pasteShortcut,
token: user.data.token,
token: user.token,
});
const cleanName = name.split('{{username}}').join(user.data.username);
const cleanName = name.split('{{username}}').join(user.username);
downloadFile(cleanName, content);
};
@ -96,7 +100,7 @@ export const ConfigGenerator = () => {
const classes = clsx(
'rounded px-2 py-1 truncate transition border border-transparent',
isSelected && 'bg-purple-600 text-white',
!isSelected && 'text-gray-400 bg-dark-100 hover:bg-dark-200 hover:text-white'
!isSelected && 'text-gray-400 bg-dark-100 hover:bg-dark-200 hover:text-white',
);
return (
@ -112,7 +116,7 @@ export const ConfigGenerator = () => {
}
}}
>
{user.data ? host.normalised.replace('{{username}}', user.data.username) : host.normalised}
{host.normalised.replace('{{username}}', user.username)}
</button>
);
})}
@ -126,10 +130,10 @@ export const ConfigGenerator = () => {
onClick={download}
className={clsx(
'mt-8 ml-auto flex items-center gap-1',
downloadable ? 'text-purple-400 hover:underline' : 'text-gray-700 cursor-not-allowed'
downloadable ? 'text-purple-400 hover:underline' : 'text-gray-700 cursor-not-allowed',
)}
>
download config <Download className="h-3.5 w-3.5" />
download config <FiDownload className="h-3.5 w-3.5" />
</button>
</Container>
</Section>

View File

@ -1,29 +0,0 @@
fragment PasteCard on Paste {
id
title
encrypted
burn
type
createdAt
expiresAt
urls {
view
}
}
fragment FileCard on File {
id
type
displayName
sizeFormatted
thumbnail {
width
height
}
paths {
thumbnail
}
urls {
view
}
}

View File

@ -1,15 +1,35 @@
import { memo, useEffect, useMemo, useState } from 'react';
import { FileMinus, Trash } from 'react-feather';
import { FiFileMinus, FiTrash } from 'react-icons/fi';
import { graphql } from '../../../@generated';
import { FileCardFragment } from '../../../@generated/graphql';
import { Link } from '../../../components/link';
import type { FileCardFragment } from '../../../generated/graphql';
import { useConfig } from '../../../hooks/useConfig';
import { MissingPreview } from '../missing-preview';
export const FileCardFrag = graphql(`
fragment FileCard on File {
id
type
displayName
sizeFormatted
thumbnail {
width
height
}
paths {
thumbnail
}
urls {
view
}
}
`);
export interface FileCardProps {
file: FileCardFragment;
}
export const FileCard = memo<FileCardProps>(({ file }) => {
export const FileCard = memo<{ file: FileCardFragment }>(({ file }) => {
const [loadFailed, setLoadFailed] = useState(false);
const config = useConfig();
const url = useMemo(() => {
@ -47,12 +67,12 @@ export const FileCard = memo<FileCardProps>(({ file }) => {
}}
/>
)}
{loadFailed && <MissingPreview text="Load Failed" icon={Trash} type={file.type} />}
{!file.paths.thumbnail && <MissingPreview text="No Preview" icon={FileMinus} type={file.type} />}
{loadFailed && <MissingPreview text="Load Failed" icon={FiTrash} type={file.type} />}
{!file.paths.thumbnail && <MissingPreview text="No Preview" icon={FiFileMinus} type={file.type} />}
</div>
<div className="py-2 px-3 text-sm text-gray-500 group-hover:text-white transition truncate flex items-center gap-2 justify-between">
<span className="truncate">{file.displayName}</span>
<span className="text-gray-700 text-xs">{file.sizeFormatted}</span>
<span className="text-gray-700 text-xs ">{file.sizeFormatted}</span>
</div>
</div>
</Link>

View File

@ -1,10 +1,26 @@
import clsx from 'clsx';
import { memo } from 'react';
import { graphql } from '../../../@generated';
import { PasteCardFragment } from '../../../@generated/graphql';
import { Link } from '../../../components/link';
import { Time } from '../../../components/time';
import type { PasteCardFragment } from '../../../generated/graphql';
import { useUser } from '../../../hooks/useUser';
export const PasteCardFrag = graphql(`
fragment PasteCard on Paste {
id
title
encrypted
burn
type
createdAt
expiresAt
urls {
view
}
}
`);
export interface PasteCardProps {
paste: PasteCardFragment;
}

View File

@ -1,31 +0,0 @@
query GetFiles($first: Float, $after: String) {
user {
files(first: $first, after: $after) {
pageInfo {
endCursor
hasNextPage
}
edges {
node {
...FileCard
}
}
}
}
}
query GetPastes($first: Float, $after: String) {
user {
pastes(first: $first, after: $after) {
pageInfo {
endCursor
hasNextPage
}
edges {
node {
...PasteCard
}
}
}
}
}

View File

@ -1,23 +1,63 @@
import { Breadcrumbs, Card } from '@ryanke/pandora';
import { useQuery } from '@apollo/client';
import type { FC } from 'react';
import { Fragment } from 'react';
import { graphql } from '../../@generated';
import { Breadcrumbs } from '../../components/breadcrumbs';
import { Card } from '../../components/card';
import { Error } from '../../components/error';
import { PageLoader } from '../../components/page-loader';
import { Toggle } from '../../components/toggle';
import { useGetFilesQuery, useGetPastesQuery } from '../../generated/graphql';
import { useQueryState } from '../../hooks/useQueryState';
import ErrorPage from '../../pages/_error';
import { FileCard } from './cards/file-card';
import { PasteCard } from './cards/paste-card';
const PER_PAGE = 24;
const GetFilesQuery = graphql(`
query GetFiles($after: String) {
user {
files(first: 24, after: $after) {
pageInfo {
endCursor
hasNextPage
}
edges {
node {
id
...FileCard
}
}
}
}
}
`);
const GetPastesQuery = graphql(`
query GetPastes($after: String) {
user {
pastes(first: 24, after: $after) {
pageInfo {
endCursor
hasNextPage
}
edges {
node {
id
...PasteCard
}
}
}
}
}
`);
export const FileList: FC = () => {
const [filter, setFilter] = useQueryState('filter', 'files');
const files = useGetFilesQuery({ skip: filter !== 'files', variables: { first: PER_PAGE } });
const pastes = useGetPastesQuery({ skip: filter !== 'pastes', variables: { first: PER_PAGE } });
const files = useQuery(GetFilesQuery, { skip: filter !== 'files' });
const pastes = useQuery(GetPastesQuery, { skip: filter !== 'pastes' });
const source = filter === 'files' ? files : pastes;
if (source.error) {
return <ErrorPage error={source.error} />;
return <Error error={source.error} />;
}
const currentPageInfo = filter === 'files' ? files.data?.user.files : pastes.data?.user.pastes;

View File

@ -1,8 +1,8 @@
import { memo } from 'react';
import type { Icon } from 'react-feather';
import { IconType } from 'react-icons/lib';
interface MissingPreviewProps {
icon: Icon;
icon: IconType;
text: string;
type: string;
}
@ -12,7 +12,7 @@ export const MissingPreview = memo<MissingPreviewProps>(({ icon: Icon, type, tex
<div className="flex flex-col justify-center items-center h-full">
<Icon className="h-5 w-5 text-gray-500 mb-2" />
<span className="text-gray-400">{text}</span>
<span className="text-sm text-gray-600">{type}</span>
<span className="text-sm text-gray-600 text-center">{type}</span>
</div>
);
});

View File

@ -1,14 +1,14 @@
import { useAsync } from '@ryanke/pandora';
import clsx from 'clsx';
import { Form, Formik } from 'formik';
import { useRouter } from 'next/router';
import type { FC } from 'react';
import { Fragment, useCallback, useEffect, useState } from 'react';
import { OtpInput } from 'src/components/input/otp';
import type { LoginMutationVariables } from 'src/generated/graphql';
import * as Yup from 'yup';
import { LoginMutationVariables } from '../@generated/graphql';
import { Input } from '../components/input/input';
import { OtpInput } from '../components/input/otp';
import { Submit } from '../components/input/submit';
import { navigate } from '../helpers/routing';
import { useAsync } from '../hooks/useAsync';
import { useUser } from '../hooks/useUser';
const schema = Yup.object().shape({
@ -18,31 +18,38 @@ const schema = Yup.object().shape({
export const LoginForm: FC = () => {
const user = useUser();
const router = useRouter();
const [loginInfo, setLoginInfo] = useState<LoginMutationVariables | null>(null);
const [invalidOTP, setInvalidOTP] = useState(false);
const [error, setError] = useState<string | null>(null);
const redirect = useCallback(() => {
const url = new URL(window.location.href);
const to = url.searchParams.get('to') ?? '/dashboard';
router.replace(to);
}, [router]);
navigate(to);
}, []);
useEffect(() => {
if (user.data) {
// redirect if the user is already signed in
redirect();
}
}, [user, router, redirect]);
}, [user, redirect]);
const [login, loggingIn] = useAsync(async (values: LoginMutationVariables) => {
try {
setLoginInfo(values);
setInvalidOTP(false);
await user.login(values);
console.log('ball');
setError(null);
redirect();
} catch (error: any) {
console.log(error);
if (user.otpRequired && error.message.toLowerCase().includes('invalid otp')) {
setInvalidOTP(true);
return;
} else if (error.message.toLowerCase().includes('unauthorized')) {
setError('Invalid username or password');
return;
}
throw error;
@ -59,7 +66,9 @@ export const LoginForm: FC = () => {
login({ ...loginInfo, otp });
}}
/>
<span className={clsx(`text-xs text-center`, invalidOTP ? 'text-red-400' : 'text-gray-600')}>
<span
className={clsx(`absolute mt-2 text-xs text-center font-mono`, invalidOTP ? 'text-red-400' : 'text-gray-600')}
>
{invalidOTP ? 'Invalid OTP code' : 'Enter the OTP code from your authenticator app'}
</span>
</div>
@ -72,9 +81,7 @@ export const LoginForm: FC = () => {
initialValues={{ username: '', password: '' }}
validationSchema={schema}
onSubmit={async (values) => {
setLoginInfo(values);
await user.login(values);
redirect();
await login(values);
}}
>
<Form>
@ -86,9 +93,10 @@ export const LoginForm: FC = () => {
autoComplete="current-password"
className="mt-2"
/>
<Submit className="mt-4" type="submit">
<Submit className="mt-4 w-full" type="submit">
Sign In
</Submit>
{error && <span className="absolute mt-2 text-xs text-center text-red-400 font-mono">{error}</span>}
</Form>
</Formik>
</Fragment>

View File

@ -45,7 +45,7 @@ export const SignupForm: FC<SignupFormProps> = ({ onSubmit }) => {
{config.data?.requireEmails && <Input id="email" type="email" placeholder="Email" autoFocus />}
<Input id="username" type="username" placeholder="Username" autoComplete="username" />
<Input id="password" type="password" placeholder="Password" autoComplete="new-password" />
<Submit className="mt-4" type="submit">
<Submit className="mt-4 w-full" type="submit">
Sign Up
</Submit>
</Form>

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,10 @@
import { ApolloError } from '@apollo/client';
import { GraphQLError } from 'graphql';
import { HTTPError } from './http.helper';
import { isObject } from './is-object.helper';
export function getErrorMessage(error: unknown): string | undefined {
if (typeof error === 'string') return error;
if (error instanceof ApolloError) {
if (error instanceof GraphQLError) {
return error.message;
}

View File

@ -3,7 +3,10 @@ import { getReasonPhrase } from 'http-status-codes';
export class HTTPError extends Error {
readonly status: number;
readonly text: string;
constructor(readonly response: Response, readonly body: any) {
constructor(
readonly response: Response,
readonly body: any,
) {
const message = body?.message ?? response.statusText;
const messageText = Array.isArray(message) ? message.join(', ') : message;
const responseText = getReasonPhrase(response.status);
@ -15,7 +18,7 @@ export class HTTPError extends Error {
}
export const isServer = typeof window === 'undefined';
export const apiUri = isServer ? process.env.API_URL : `/api`;
export const apiUri = isServer ? process.env.FRONTEND_API_URL : `/api`;
export async function http(pathOrUrl: string, options?: RequestInit): Promise<Response> {
const hasProtocol = pathOrUrl.startsWith('http');

View File

@ -0,0 +1,12 @@
export const navigate = (url: string, options?: { overwriteLastHistoryEntry: boolean }) => {
window.location.href = url;
};
export const reload = () => {
window.location.reload();
};
export const prefetch = (url: string) => {
// todo: no-op from client routing days,
// left because it might be useful in the future.
};

View File

@ -0,0 +1,28 @@
import { useState } from "react";
export function useAsync<T, X extends any[]>(handler: (...params: X) => Promise<T>) {
const [promise, setPromise] = useState<Promise<T> | null>(null);
const [error, setError] = useState<Error | null>(null);
const [result, setResult] = useState<T | null>(null);
const running = !!promise;
const run = async (...params: X) => {
if (promise) {
return promise;
}
try {
const promise = handler(...params);
setPromise(promise);
setError(null);
const result = await promise;
setResult(result);
} catch (error: any) {
setError(error);
throw error;
} finally {
setPromise(null);
}
};
return [run, running, error, result] as const;
}

View File

@ -1,19 +0,0 @@
query Config {
config {
allowTypes
inquiriesEmail
requireEmails
uploadLimit
currentHost {
normalised
redirect
}
rootHost {
normalised
url
}
hosts {
normalised
}
}
}

View File

@ -1,7 +1,30 @@
import { useConfigQuery } from '../generated/graphql';
import { useQuery } from '@apollo/client';
import { graphql } from '../@generated';
const ConfigQuery = graphql(`
query Config {
config {
allowTypes
inquiriesEmail
requireEmails
uploadLimit
currentHost {
normalised
redirect
}
rootHost {
normalised
url
}
hosts {
normalised
}
}
}
`);
export const useConfig = () => {
const config = useConfigQuery();
const config = useQuery(ConfigQuery);
return {
...config,
data: config.data?.config,

View File

@ -0,0 +1,24 @@
import { useEffect } from "react";
export function useOnClickOutside(ref: React.MutableRefObject<any>, handler: () => void) {
useEffect(() => {
const onClick = (event: Event) => {
if (!ref.current || ref.current.contains(event.target)) return;
handler();
};
const onKeyPress = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
handler();
};
document.addEventListener("mousedown", onClick);
document.addEventListener("touchstart", onClick);
document.addEventListener("keydown", onKeyPress);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("touchstart", onClick);
document.removeEventListener("keydown", onKeyPress);
};
}, [ref, handler]);
}

View File

@ -1,16 +1,30 @@
import { useEffect, useState } from 'react';
import { usePageContext } from '../renderer/usePageContext';
export const useQueryState = <S>(key: string, initialState?: S, parser?: (input: string) => S) => {
const [value, setValue] = useState<S>(initialState as any);
useEffect(() => {
const search = new URLSearchParams(window.location.search);
const value = search.get(key);
if (value) {
const result = parser ? parser(value) : (value as any);
setValue(result);
const pageContext = usePageContext();
const [value, setValue] = useState<S>(() => {
if (typeof window === 'undefined') {
// during SSR, we can grab query params from the page context
const value = pageContext.urlParsed.search[key];
if (value) {
const result = parser ? parser(value) : (value as any);
return result;
}
}
}, []);
if (typeof window !== 'undefined' && window.location.search) {
// during
const search = new URLSearchParams(window.location.search);
const value = search.get(key);
if (value) {
const result = parser ? parser(value) : (value as any);
return result;
}
}
return initialState;
});
useEffect(() => {
const route = new URL(window.location.href);

View File

@ -1,40 +0,0 @@
query GetUser {
user {
...RegularUser
otpEnabled
}
}
fragment RegularUser on User {
id
username
email
verifiedEmail
token
}
mutation Login($username: String!, $password: String!, $otp: String) {
login(username: $username, password: $password, otpCode: $otp) {
...RegularUser
}
}
mutation Logout {
logout
}
mutation GenerateOTP {
generateOTP {
recoveryCodes
qrauthUrl
secret
}
}
mutation ConfirmOTP($otpCode: String!) {
confirmOTP(otpCode: $otpCode)
}
mutation DisableOTP($otpCode: String!) {
disableOTP(otpCode: $otpCode)
}

View File

@ -1,27 +1,49 @@
import { useAsync } from '@ryanke/pandora';
import Router, { useRouter } from 'next/router';
import { TypedDocumentNode, useMutation, useQuery } from '@apollo/client';
import { useEffect, useState } from 'react';
import { resetClient } from '../apollo';
import type { LoginMutationVariables } from '../generated/graphql';
import { useGetUserQuery, useLoginMutation, useLogoutMutation } from '../generated/graphql';
import { graphql } from '../@generated';
import type { GetUserQuery, LoginMutationVariables, RegularUserFragment } from '../@generated/graphql';
import { navigate, reload } from '../helpers/routing';
import { useAsync } from './useAsync';
export const useUser = (redirect = false) => {
const user = useGetUserQuery();
const router = useRouter();
const [loginMutation] = useLoginMutation();
const [logoutMutation] = useLogoutMutation();
const RegularUserFragment = graphql(`
fragment RegularUser on User {
id
username
email
verifiedEmail
}
`);
const UserQuery = graphql(`
query GetUser {
user {
...RegularUser
}
}
`);
const LoginMutation = graphql(`
mutation Login($username: String!, $password: String!, $otp: String) {
login(username: $username, password: $password, otpCode: $otp) {
...RegularUser
}
}
`);
const LogoutMutation = graphql(`
mutation Logout {
logout
}
`);
export const useLoginUser = () => {
const [otp, setOtp] = useState(false);
const [loginMutation] = useMutation(LoginMutation);
const [login] = useAsync(async (variables: LoginMutationVariables) => {
try {
await loginMutation({
variables: variables,
});
await user.refetch();
Router.push('/dashboard');
await loginMutation({ variables });
navigate('/dashboard');
} catch (error: any) {
console.log({ error });
if (error.message.toLowerCase().includes('otp')) {
setOtp(true);
}
@ -30,22 +52,46 @@ export const useUser = (redirect = false) => {
}
});
return {
login,
otpRequired: otp,
};
};
export const useLogoutUser = () => {
const [logoutMutation] = useMutation(LogoutMutation);
const [logout] = useAsync(async () => {
await logoutMutation();
resetClient();
await logoutMutation({});
reload();
});
return { logout };
};
export const useUserRedirect = (
data: RegularUserFragment | null | undefined,
loading: boolean,
redirect: boolean | undefined,
) => {
useEffect(() => {
if (!user.data && !user.loading && redirect) {
router.push(`/login?to=${router.asPath}`);
if (!data && !loading && redirect) {
navigate(`/login?to=${window.location.href}`);
}
}, [router, redirect, user.data, user.loading]);
}, [redirect, data, loading]);
};
export const useUser = <T extends TypedDocumentNode<GetUserQuery, any>>(redirect?: boolean, query?: T) => {
const { login, otpRequired } = useLoginUser();
const { logout } = useLogoutUser();
const { data, loading, error } = useQuery((query || UserQuery) as T);
useUserRedirect(data?.user, loading, redirect);
return {
data: user.data?.user,
error: user.error,
loading: user.loading,
otpRequired: otp,
data: data?.user as RegularUserFragment | null | undefined,
loading: loading,
error: error,
otpRequired: otpRequired,
login: login,
logout: logout,
} as const;

View File

@ -1,37 +0,0 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
// /i = image
// /v = video
const REDIRECT_URL_REGEX = /\/(i|v)\/(?<id>[\dA-z]+)($|\?|#)/iu;
const REDIRECT_CONTENT_UAS = [
'Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)', // Discord
'Mozilla/5.0 (Macintosh; Intel Mac OS X 11.6; rv:92.0) Gecko/20100101 Firefox/92.0', // Discord
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:38.0) Gecko/20100101 Firefox/38.0', // Discord
'wget/',
'curl/',
];
export function redirectAcceptHeader(acceptHeader: string | null) {
if (!acceptHeader) return false;
if (acceptHeader.includes('image/*')) return true;
if (acceptHeader.includes('video/*')) return true;
return false;
}
export async function middleware(request: NextRequest) {
// match /i and /v urls and redirect them to the content url
// this is how pasting an embedded image link in discord still embeds the image
// /i and /v routes are necessary so we dont have to lookup the type of the file, because we still
// want to return html for other files so we can give opengraph tags about the files that discord wont embed directly.
const redirectUrlMatch = REDIRECT_URL_REGEX.exec(request.url);
if (redirectUrlMatch) {
const fileId = redirectUrlMatch.groups!.id;
const accept = request.headers.get('accept');
const userAgent = request.headers.get('user-agent');
const isScrapingUA = userAgent && REDIRECT_CONTENT_UAS.some((ua) => userAgent.startsWith(ua));
if (redirectAcceptHeader(accept) || isScrapingUA) {
return NextResponse.redirect(new URL(`/api/file/${fileId}`, request.url));
}
}
}

View File

@ -1,7 +1,9 @@
import { Spinner, Container } from '@ryanke/pandora';
import { FC } from 'react';
import { Container } from '../components/container';
import { useConfig } from '../hooks/useConfig';
import { Spinner } from '../components/spinner';
export default function Home() {
export const Page: FC = () => {
const config = useConfig();
const hosts = config.data?.hosts ?? [];
const loading = !config.data && !config.error;
@ -35,4 +37,4 @@ export default function Home() {
</div>
</Container>
);
}
};

View File

@ -1,5 +0,0 @@
import ErrorPage, { Lenny } from './_error';
export default function NotFound() {
return <ErrorPage message="This ain't it chief" lenny={Lenny.Shrug} />;
}

View File

@ -1,5 +0,0 @@
import ErrorPage, { Lenny } from './_error';
export default function InternalServerError() {
return <ErrorPage message="Internal Server Error" lenny={Lenny.Shrug} />;
}

View File

@ -1,28 +0,0 @@
import { ApolloProvider } from '@apollo/client';
import type { AppProps } from 'next/app';
import Head from 'next/head';
import { useApollo } from '../apollo';
import { Header } from '../components/header/header';
import { Title } from '../components/title';
import { ToastProvider } from '@ryanke/pandora';
import '../styles/globals.css';
export default function App({ Component, pageProps }: AppProps) {
const client = useApollo(pageProps);
return (
<ApolloProvider client={client}>
<Title>Home</Title>
<Head>
<meta property="og:site_name" content="micro" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</Head>
<ToastProvider>
<Header />
<div className="py-4 md:py-16">
<Component {...pageProps} />
</div>
</ToastProvider>
</ApolloProvider>
);
}

View File

@ -0,0 +1,6 @@
import { Error, Lenny } from '../../components/error';
// is404 doesn't appear to actually be set, so...
export default function ErrorPage({ is404 }: { is404: boolean }) {
return <Error lenny={Lenny.Angry} message="This page doesn't exist, or something went wrong rendering it." />;
}

View File

@ -1,13 +1,16 @@
import { Container } from '@ryanke/pandora';
import { FC } from 'react';
import { Container } from '../../components/container';
import { Title } from '../../components/title';
import { FileList } from '../../containers/file-list/file-list';
import { useUser } from '../../hooks/useUser';
export default function Dashboard() {
export const Page: FC = () => {
useUser(true);
return (
<Container className="mt-4">
<Title>Dashboard</Title>
<FileList />
</Container>
);
}
};

View File

@ -1,45 +1,48 @@
import { Button, ButtonStyle, Container, useAsync, useToasts } from '@ryanke/pandora';
import { useMutation, useQuery } from '@apollo/client';
import clsx from 'clsx';
import { useRouter } from 'next/router';
import { QRCodeSVG } from 'qrcode.react';
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight, Copy, Download } from 'react-feather';
import { OtpInput } from 'src/components/input/otp';
import { PageLoader } from 'src/components/page-loader';
import { Steps } from 'src/components/steps';
import type { GenerateOtpMutation } from 'src/generated/graphql';
import { useConfirmOtpMutation, useGenerateOtpMutation } from 'src/generated/graphql';
import { useQueryState } from 'src/hooks/useQueryState';
import { FC, Fragment, useCallback, useMemo } from 'react';
import { FiChevronLeft, FiChevronRight, FiCopy, FiDownload } from 'react-icons/fi';
import { graphql } from '../../../@generated';
import { Button, ButtonStyle } from '../../../components/button';
import { Container } from '../../../components/container';
import { Error } from '../../../components/error';
import { OtpInput } from '../../../components/input/otp';
import { PageLoader } from '../../../components/page-loader';
import { Steps } from '../../../components/steps';
import { useToasts } from '../../../components/toast';
import { navigate } from '../../../helpers/routing';
import { useAsync } from '../../../hooks/useAsync';
import { useQueryState } from '../../../hooks/useQueryState';
export default function Generate() {
const [generate] = useGenerateOtpMutation();
const [result, setResult] = useState<GenerateOtpMutation | null>(null);
const GenerateOtp = graphql(`
query GenerateOTP {
generateOTP {
recoveryCodes
qrauthUrl
secret
}
}
`);
const ConfirmOTP = graphql(`
mutation ConfirmOTP($otpCode: String!) {
confirmOTP(otpCode: $otpCode)
}
`);
export const Page: FC = () => {
const result = useQuery(GenerateOtp);
const createToast = useToasts();
const router = useRouter();
const [currentStep, setCurrentStep] = useQueryState('step', 0, Number);
const [confirmOtp] = useConfirmOtpMutation();
useEffect(() => {
generate()
.then(({ data }) => {
if (!data) return;
setResult(data);
})
.catch((error) => {
createToast({
text: error.message,
error: true,
});
router.push('/dashboard');
});
}, []);
const [confirmOtp] = useMutation(ConfirmOTP);
const copyable = useMemo(() => {
if (!result) return;
const prefix = `Use these in place of OTP codes in emergency situations on ${window.location.host}. \nEach code will only work once. If you are close to running out, you should generate new codes.\n\n`;
const body = result.generateOTP.recoveryCodes.join('\n');
if (!result.data) return;
const prefix = `Use these in place of OTP codes in emergency situations. \nEach code will only work once. If you are close to running out, you should generate new codes.\n\n`;
const body = result.data.generateOTP.recoveryCodes.join('\n');
return prefix + body;
}, [result]);
}, [result.data]);
const download = useCallback(() => {
if (!copyable) return;
@ -63,7 +66,7 @@ export default function Generate() {
try {
await confirmOtp({ variables: { otpCode } });
createToast({ text: 'Successfully enabled 2FA!' });
router.replace('/dashboard');
navigate('/dashboard', { overwriteLastHistoryEntry: true });
} catch (error: any) {
if (error.message) {
createToast({
@ -74,9 +77,8 @@ export default function Generate() {
}
});
if (!result) {
return <PageLoader />;
}
if (result.loading) return <PageLoader />;
if (!result.data) return <Error error={result.error} />;
return (
<Container>
@ -86,7 +88,7 @@ export default function Generate() {
{currentStep === 0 && (
<Fragment>
<div className="bg-gray-900 p-4 h-min rounded-xl font-mono whitespace-pre truncate text-gray-600 col-span-2 select-none text-center">
{result.generateOTP.recoveryCodes.join('\n')}
{result.data.generateOTP.recoveryCodes.join('\n')}
</div>
<div className="col-span-4">
<h2>Store your backup codes</h2>
@ -96,11 +98,11 @@ export default function Generate() {
</p>
<div className="flex mt-8 gap-2">
<Button style={ButtonStyle.Secondary} className="w-auto" onClick={download}>
<Download className="h-3.5 w-3.5" />
<FiDownload className="h-3.5 w-3.5" />
Download Codes
</Button>
<Button style={ButtonStyle.Secondary} className="w-auto" onClick={copy}>
<Copy className="h-3.5 w-3.5" />
<FiCopy className="h-3.5 w-3.5" />
Copy Codes
</Button>
</div>
@ -110,7 +112,7 @@ export default function Generate() {
{currentStep === 1 && (
<Fragment>
<div className="p-4 rounded-xl h-min bg-white col-span-2">
<QRCodeSVG className="w-full h-full" size={128} value={result.generateOTP.qrauthUrl} />
<QRCodeSVG className="w-full h-full" size={128} value={result.data.generateOTP.qrauthUrl} />
</div>
<div className="col-span-4">
<h2>Scan the QR code</h2>
@ -121,7 +123,7 @@ export default function Generate() {
</p>
<p className="text-xs text-gray-600">
If you can&apos;t scan the QR code, you can enter the code{' '}
<code className="text-purple-400">{result.generateOTP.secret}</code> manually.
<code className="text-purple-400">{result.data.generateOTP.secret}</code> manually.
</p>
</div>
</div>
@ -151,17 +153,17 @@ export default function Generate() {
currentStep === 0 && 'opacity-0 pointer-events-none',
)}
>
<ChevronLeft className="h-4 w-4" /> Back
<FiChevronLeft className="h-4 w-4" /> Back
</button>
<Button
onClick={() => setCurrentStep((prev) => prev + 1)}
disabled={currentStep === 2}
className="w-auto ml-auto"
>
Next <ChevronRight className="h-4 w-4" />
Next <FiChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</Container>
);
}
};

View File

@ -1,5 +0,0 @@
mutation RefreshToken {
refreshToken {
...RegularUser
}
}

View File

@ -1,28 +1,60 @@
import { Breadcrumbs, Button, Container, useAsync } from '@ryanke/pandora';
import { useRouter } from 'next/router';
import { OtpInput } from 'src/components/input/otp';
import { Input } from '../../components/input/input';
import { PageLoader } from '../../components/page-loader';
import { Title } from '../../components/title';
import { ConfigGenerator } from '../../containers/config-generator/config-generator';
import { GetUserDocument, useDisableOtpMutation, useRefreshTokenMutation } from '../../generated/graphql';
import { useConfig } from '../../hooks/useConfig';
import { useUser } from '../../hooks/useUser';
import { useMutation, useQuery } from '@apollo/client';
import { FC } from 'react';
import { graphql } from '../../../@generated';
import { GetUserDocument } from '../../../@generated/graphql';
import { Breadcrumbs } from '../../../components/breadcrumbs';
import { Button } from '../../../components/button';
import { Container } from '../../../components/container';
import { Input } from '../../../components/input/input';
import { OtpInput } from '../../../components/input/otp';
import { PageLoader } from '../../../components/page-loader';
import { Title } from '../../../components/title';
import { ConfigGenerator } from '../../../containers/config-generator/config-generator';
import { navigate } from '../../../helpers/routing';
import { useAsync } from '../../../hooks/useAsync';
import { useConfig } from '../../../hooks/useConfig';
import { useLogoutUser, useUserRedirect } from '../../../hooks/useUser';
export default function Preferences() {
const user = useUser(true);
const RefreshToken = graphql(`
mutation RefreshToken {
refreshToken {
...RegularUser
}
}
`);
const DisableOtp = graphql(`
mutation DisableOTP($otpCode: String!) {
disableOTP(otpCode: $otpCode)
}
`);
const UserQueryWithToken = graphql(`
query UserQueryWithToken {
user {
...RegularUser
token
otpEnabled
}
}
`);
export const Page: FC = () => {
const { logout } = useLogoutUser();
const user = useQuery(UserQueryWithToken);
const config = useConfig();
const router = useRouter();
const [refreshMutation] = useRefreshTokenMutation();
const [refreshMutation] = useMutation(RefreshToken);
const [refresh, refreshing] = useAsync(async () => {
// eslint-disable-next-line no-alert
const confirmation = confirm('Are you sure? This will invalidate all existing configs and sessions and will sign you out of the dashboard.') // prettier-ignore
if (!confirmation) return;
await refreshMutation();
await user.logout();
await logout();
});
const [disableOTP, disableOTPMut] = useDisableOtpMutation({
useUserRedirect(user.data?.user, user.loading, true);
const [disableOTP, disableOTPMut] = useMutation(DisableOtp, {
refetchQueries: [{ query: GetUserDocument }],
});
@ -50,7 +82,7 @@ export default function Preferences() {
<div className="right flex items-center col-span-full md:col-span-1">
<Input
readOnly
value={user.data.token}
value={user.data.user.token}
onFocus={(event) => {
event.target.select();
}}
@ -58,18 +90,18 @@ export default function Preferences() {
</div>
</div>
<div className="mt-10">
<ConfigGenerator />
<ConfigGenerator user={user.data.user} />
</div>
<div className="grid grid-cols-2 gap-4 mt-8">
<div className="left col-span-full md:col-span-1">
<div className="font-bold text-xl">2-factor Authentication</div>
<p className="text-sm mt-2 text-gray-400">
2-factor authentication is currently {user.data.otpEnabled ? 'enabled' : 'disabled'}.{' '}
{user.data.otpEnabled ? `Enter an authenticator code to disable it.` : 'Click to setup.'}
2-factor authentication is currently {user.data.user.otpEnabled ? 'enabled' : 'disabled'}.{' '}
{user.data.user.otpEnabled ? `Enter an authenticator code to disable it.` : 'Click to setup.'}
</p>
</div>
<div className="right flex items-center col-span-full md:col-span-1">
{user.data.otpEnabled && (
{user.data.user.otpEnabled && (
<OtpInput
loading={disableOTPMut.loading}
onCode={(otpCode) => {
@ -79,8 +111,8 @@ export default function Preferences() {
}}
/>
)}
{!user.data.otpEnabled && (
<Button className="w-auto ml-auto" onClick={() => router.push(`/dashboard/mfa`)}>
{!user.data.user.otpEnabled && (
<Button className="w-auto ml-auto" onClick={() => navigate(`/dashboard/mfa`)}>
Enable 2FA
</Button>
)}
@ -88,4 +120,4 @@ export default function Preferences() {
</div>
</Container>
);
}
};

View File

@ -1,19 +1,54 @@
import { Container, Spinner, useAsync, useToasts } from '@ryanke/pandora';
import { useMutation, useQuery } from '@apollo/client';
import clsx from 'clsx';
import copyToClipboard from 'copy-to-clipboard';
import type { GetServerSidePropsContext } from 'next';
import { useRouter } from 'next/router';
import type { FC, ReactNode } from 'react';
import { useState } from 'react';
import { Download, Share, Trash } from 'react-feather';
import { addStateToPageProps, initializeApollo } from '../../apollo';
import { Embed } from '../../components/embed/embed';
import { PageLoader } from '../../components/page-loader';
import { Title } from '../../components/title';
import { ConfigDocument, GetFileDocument, useDeleteFileMutation, useGetFileQuery } from '../../generated/graphql';
import { downloadUrl } from '../../helpers/download.helper';
import { useQueryState } from '../../hooks/useQueryState';
import ErrorPage from '../_error';
import { FiDownload, FiShare, FiTrash } from 'react-icons/fi';
import { graphql } from '../../../@generated';
import { Container } from '../../../components/container';
import { Embed } from '../../../components/embed/embed';
import { Error } from '../../../components/error';
import { PageLoader } from '../../../components/page-loader';
import { Spinner } from '../../../components/spinner';
import { Title } from '../../../components/title';
import { useToasts } from '../../../components/toast';
import { downloadUrl } from '../../../helpers/download.helper';
import { navigate } from '../../../helpers/routing';
import { useAsync } from '../../../hooks/useAsync';
import { useQueryState } from '../../../hooks/useQueryState';
import { PageProps } from '../../../renderer/types';
const GetFile = graphql(`
query GetFile($fileId: ID!) {
file(fileId: $fileId) {
id
type
displayName
size
sizeFormatted
textContent
isOwner
metadata {
height
width
}
paths {
view
thumbnail
direct
}
urls {
view
}
}
}
`);
const DeleteFile = graphql(`
mutation DeleteFile($fileId: ID!, $deleteKey: String) {
deleteFile(fileId: $fileId, key: $deleteKey)
}
`);
const FileOption: FC<{ children: ReactNode; className?: string; onClick: () => void }> = ({
children,
@ -32,20 +67,19 @@ const FileOption: FC<{ children: ReactNode; className?: string; onClick: () => v
);
};
export default function File() {
const router = useRouter();
const fileId = router.query.fileId;
export const Page: FC<PageProps> = ({ routeParams }) => {
const fileId = routeParams.fileId;
const [deleteKey] = useQueryState<string | undefined>('deleteKey');
const [confirm, setConfirm] = useState(false);
const createToast = useToasts();
const file = useGetFileQuery({
const file = useQuery(GetFile, {
skip: !fileId,
variables: {
fileId: fileId as string,
},
});
const [deleteMutation] = useDeleteFileMutation();
const [deleteMutation] = useMutation(DeleteFile);
const copyLink = () => {
copyToClipboard(file.data?.file.urls.view ?? window.location.href);
createToast({
@ -73,11 +107,11 @@ export default function File() {
});
createToast({ text: `Deleted "${file.data.file.displayName}"` });
router.replace('/dashboard');
navigate('/dashboard', { overwriteLastHistoryEntry: true });
});
if (file.error) {
return <ErrorPage error={file.error} />;
return <Error error={file.error} />;
}
if (!file.data) {
@ -103,20 +137,21 @@ export default function File() {
displayName: file.data.file.displayName,
height: file.data.file.metadata?.height,
width: file.data.file.metadata?.width,
textContent: file.data.file.textContent,
}}
/>
</div>
<div className="flex md:flex-col">
<div className="flex text-sm gap-3 text-gray-500 cursor-pointer md:flex-col">
<FileOption onClick={copyLink}>
<Share className="h-4 mr-1" /> Copy link
<FiShare className="h-4 mr-1" /> Copy link
</FileOption>
<FileOption onClick={downloadFile}>
<Download className="h-4 mr-1" /> Download
<FiDownload className="h-4 mr-1" /> Download
</FileOption>
{canDelete && (
<FileOption onClick={deleteFile} className="text-red-400 hover:text-red-500">
<Trash className="h-4 mr-1" />
<FiTrash className="h-4 mr-1" />
{deletingFile ? <Spinner size="small" /> : confirm ? 'Are you sure?' : 'Delete'}
</FileOption>
)}
@ -125,23 +160,4 @@ export default function File() {
</div>
</Container>
);
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const client = initializeApollo({ context });
await Promise.all([
await client.query({
query: ConfigDocument,
}),
await client.query({
query: GetFileDocument,
variables: {
fileId: context.query.fileId,
},
}),
]);
return addStateToPageProps(client, {
props: {},
});
}
};

View File

@ -0,0 +1,13 @@
import { PageContext, RouteSync } from 'vike/types';
import { resolveRoute } from 'vike/routing';
const PATTERN = /^\/(file|f|v|i)\//;
export const route: RouteSync = (pageContext: PageContext) => {
if (PATTERN.test(pageContext.urlPathname)) {
const replaced = pageContext.urlPathname.replace(PATTERN, '/file/');
return resolveRoute('/file/@fileId', replaced);
}
return false;
};

View File

@ -1,26 +0,0 @@
query GetFile($fileId: ID!) {
file(fileId: $fileId) {
id
type
displayName
size
sizeFormatted
isOwner
metadata {
height
width
}
paths {
view
thumbnail
direct
}
urls {
view
}
}
}
mutation DeleteFile($fileId: ID!, $deleteKey: String) {
deleteFile(fileId: $fileId, key: $deleteKey)
}

View File

@ -1,29 +1,49 @@
import { Container, useAsync, useToasts } from '@ryanke/pandora';
import Router, { useRouter } from 'next/router';
import { useEffect } from 'react';
import { PageLoader } from '../../components/page-loader';
import { Time } from '../../components/time';
import { Title } from '../../components/title';
import type { SignupData } from '../../containers/signup-form';
import { SignupForm } from '../../containers/signup-form';
import { useCreateUserMutation, useGetInviteQuery } from '../../generated/graphql';
import { getErrorMessage } from '../../helpers/get-error-message.helper';
import { useConfig } from '../../hooks/useConfig';
import ErrorPage from '../_error';
import { useMutation, useQuery } from '@apollo/client';
import { FC, useEffect } from 'react';
import { graphql } from '../../../@generated';
import { Container } from '../../../components/container';
import { Error } from '../../../components/error';
import { PageLoader } from '../../../components/page-loader';
import { Time } from '../../../components/time';
import { Title } from '../../../components/title';
import { useToasts } from '../../../components/toast';
import type { SignupData } from '../../../containers/signup-form';
import { SignupForm } from '../../../containers/signup-form';
import { getErrorMessage } from '../../../helpers/get-error-message.helper';
import { navigate, prefetch } from '../../../helpers/routing';
import { useAsync } from '../../../hooks/useAsync';
import { useConfig } from '../../../hooks/useConfig';
import { PageProps } from '../../../renderer/types';
export default function Invite() {
const GetInvite = graphql(`
query GetInvite($inviteId: ID!) {
invite(inviteId: $inviteId) {
id
expiresAt
}
}
`);
const CreateUser = graphql(`
mutation CreateUser($user: CreateUserDto!) {
createUser(data: $user) {
id
}
}
`);
export const Page: FC<PageProps> = ({ routeParams }) => {
const config = useConfig();
const router = useRouter();
const createToast = useToasts();
const inviteToken = router.query.inviteToken as string | undefined;
const invite = useGetInviteQuery({ skip: !inviteToken, variables: { inviteId: inviteToken! } });
const inviteToken = routeParams.inviteToken;
const invite = useQuery(GetInvite, { skip: !inviteToken, variables: { inviteId: inviteToken! } });
const expiresAt = invite.data?.invite.expiresAt;
useEffect(() => {
Router.prefetch('/login');
prefetch('/login');
}, []);
const [createUserMutation] = useCreateUserMutation();
const [createUserMutation] = useMutation(CreateUser);
const [onSubmit] = useAsync(async (data: SignupData) => {
try {
if (!inviteToken) return;
@ -36,7 +56,7 @@ export default function Invite() {
},
});
Router.push('/login');
navigate('/login');
createToast({ text: 'Account created successfully. Please sign in.' });
} catch (error) {
const message = getErrorMessage(error);
@ -47,7 +67,7 @@ export default function Invite() {
});
if (invite.error || config.error) {
return <ErrorPage error={invite.error || config.error} />;
return <Error error={invite.error || config.error} />;
}
if (!invite.data || !config.data) {
@ -82,4 +102,4 @@ export default function Invite() {
</div>
</Container>
);
}
};

View File

@ -1,12 +0,0 @@
query GetInvite($inviteId: ID!) {
invite(inviteId: $inviteId) {
id
expiresAt
}
}
mutation CreateUser($user: CreateUserDto!) {
createUser(data: $user) {
id
}
}

View File

@ -1,9 +1,10 @@
import { Container, useToasts } from '@ryanke/pandora';
import { useEffect } from 'react';
import { Title } from '../components/title';
import { LoginForm } from '../containers/login-form';
import { FC, useEffect } from 'react';
import { Title } from '../../components/title';
import { LoginForm } from '../../containers/login-form';
import { Container } from '../../components/container';
import { useToasts } from '../../components/toast';
export default function Login() {
export const Page: FC = () => {
const createToast = useToasts();
useEffect(() => {
@ -25,4 +26,4 @@ export default function Login() {
<LoginForm />
</Container>
);
}
};

View File

@ -1,17 +1,20 @@
import { Button, Container } from '@ryanke/pandora';
import { useMutation } from '@apollo/client';
import { Form, Formik } from 'formik';
import { FC } from 'react';
import * as Yup from 'yup';
import { graphql } from '../../@generated';
import type { CreatePasteDto } from '../../@generated/graphql';
import { Button } from '../../components/button';
import { Container } from '../../components/container';
import { Error } from '../../components/error';
import { Checkbox } from '../../components/input/checkbox';
import { Input } from '../../components/input/input';
import { Select } from '../../components/input/select';
import { TextArea } from '../../components/input/textarea';
import { Title } from '../../components/title';
import type { CreatePasteDto } from '../../generated/graphql';
import { useCreatePasteMutation } from '../../generated/graphql';
import { encryptContent } from '../../helpers/encrypt.helper';
import { useConfig } from '../../hooks/useConfig';
import { useUser } from '../../hooks/useUser';
import ErrorPage from '../_error';
const EXPIRY_OPTIONS = [
{ name: '15 minutes', value: 15 },
@ -81,12 +84,23 @@ const schema = Yup.object().shape({
expiryMinutes: Yup.number().required(),
});
export default function Paste() {
const CreatePaste = graphql(`
mutation CreatePaste($input: CreatePasteDto!) {
createPaste(partial: $input) {
id
urls {
view
}
}
}
`);
export const Page: FC = () => {
const user = useUser();
const config = useConfig();
const [pasteMutation] = useCreatePasteMutation();
const [pasteMutation] = useMutation(CreatePaste);
if (user.error) {
return <ErrorPage error={user.error} />;
return <Error error={user.error} />;
}
return (
@ -200,4 +214,4 @@ export default function Paste() {
</Formik>
</Container>
);
}
};

View File

@ -1,31 +1,54 @@
import { Button, Container } from '@ryanke/pandora';
import type { GetServerSidePropsContext } from 'next';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { BookOpen, Clock, Trash } from 'react-feather';
import { addStateToPageProps, initializeApollo } from '../../apollo';
import { Embed } from '../../components/embed/embed';
import { PageLoader } from '../../components/page-loader';
import { Time } from '../../components/time';
import { ConfigDocument, GetPasteDocument, useGetPasteQuery } from '../../generated/graphql';
import { decryptContent } from '../../helpers/encrypt.helper';
import { hashToObject } from '../../helpers/hash-to-object';
import { useUser } from '../../hooks/useUser';
import { Warning } from '../../warning';
import ErrorPage, { Lenny } from '../_error';
import { useQuery } from '@apollo/client';
import { FC, useEffect, useState } from 'react';
import { FiBookOpen, FiClock, FiTrash } from 'react-icons/fi';
import { graphql } from '../../../@generated';
import { Button } from '../../../components/button';
import { Container } from '../../../components/container';
import { Embed } from '../../../components/embed/embed';
import { Error, Lenny } from '../../../components/error';
import { PageLoader } from '../../../components/page-loader';
import { Time } from '../../../components/time';
import { Warning } from '../../../components/warning';
import { decryptContent } from '../../../helpers/encrypt.helper';
import { hashToObject } from '../../../helpers/hash-to-object';
import { navigate } from '../../../helpers/routing';
import { useUser } from '../../../hooks/useUser';
import { PageProps } from '../../../renderer/types';
export default function ViewPaste() {
const PasteQuery = graphql(`
query GetPaste($pasteId: ID!) {
paste(pasteId: $pasteId) {
id
title
type
extension
content
encrypted
createdAt
expiresAt
burnt
burn
urls {
view
}
}
}
`);
export const Page: FC<PageProps> = ({ routeParams }) => {
const user = useUser();
const router = useRouter();
const [burnUnless, setBurnUnless] = useState<string | null | undefined>();
const [confirmedBurn, setConfirmedBurn] = useState(false);
const [content, setContent] = useState<string | null>(null);
const [error, setError] = useState<any>(null);
const [missingKey, setMissingKey] = useState(false);
const pasteId = router.query.pasteId as string | undefined;
const paste = useGetPasteQuery({
skip:
!pasteId || (!confirmedBurn && (burnUnless === undefined || (burnUnless ? burnUnless !== user.data?.id : false))),
const pasteId = routeParams.pasteId;
const skipQuery =
!pasteId || (!confirmedBurn && (burnUnless === undefined || (burnUnless ? burnUnless !== user.data?.id : false)));
const paste = useQuery(PasteQuery, {
skip: skipQuery,
variables: {
pasteId: pasteId!,
},
@ -41,8 +64,8 @@ export default function ViewPaste() {
setBurnUnless(burnUnless);
url.searchParams.delete('burn_unless');
window.history.replaceState(window.history.state, '', url.href);
}, [router]);
navigate(url.href, { overwriteLastHistoryEntry: true });
}, []);
useEffect(() => {
// handle decrypting encrypted pastes
@ -77,7 +100,7 @@ export default function ViewPaste() {
}, [content, paste.data]);
if (paste.error || error) {
return <ErrorPage error={paste.error ?? error} />;
return <Error error={paste.error ?? error} />;
}
if (!confirmedBurn && burnUnless && (!user.data || burnUnless !== user.data.id)) {
@ -96,7 +119,7 @@ export default function ViewPaste() {
if (missingKey) {
return (
<ErrorPage
<Error
lenny={Lenny.Crying}
message="This paste is encrypted and requires the encryption key to be provided in the URL."
/>
@ -126,7 +149,7 @@ export default function ViewPaste() {
type: 'text/plain',
size: paste.data.paste.content.length,
displayName: paste.data.paste.title ?? `${paste.data.paste.id}.${paste.data.paste.extension}`,
content: { data: content, error: error },
textContent: paste.data.paste.content,
paths: {
view: `/paste/${pasteId}`,
direct: `/paste/${pasteId}.txt`,
@ -135,17 +158,17 @@ export default function ViewPaste() {
/>
<p className="text-gray-600 text-sm mt-2 flex items-center gap-2 flex-wrap">
<span className="flex items-center gap-2">
<BookOpen className="h-4 w-4" /> {content?.length ?? 0} characters
<FiBookOpen className="h-4 w-4" /> {content?.length ?? 0} characters
</span>
<span className="flex items-center gap-2">
<Clock className="h-4 w-4" />{' '}
<FiClock className="h-4 w-4" />{' '}
<span>
Created <Time date={paste.data.paste.createdAt} />
</span>
</span>
{paste.data.paste.expiresAt && !confirmedBurn && (
<span className="flex items-center gap-2">
<Trash className="h-4 w-4" />{' '}
<FiTrash className="h-4 w-4" />{' '}
<span>
Expires <Time date={paste.data.paste.expiresAt} />
</span>
@ -154,23 +177,4 @@ export default function ViewPaste() {
</p>
</Container>
);
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const client = initializeApollo({ context });
await Promise.all([
await client.query({
query: ConfigDocument,
}),
await client.query({
query: GetPasteDocument,
variables: {
pasteId: context.query.pasteId,
},
}),
]);
return addStateToPageProps(client, {
props: {},
});
}
};

Some files were not shown because too many files have changed in this diff Show More