diff --git a/apps/cli/bin/index.js b/apps/cli/bin/index.js index f6bbae0..809f49b 100644 --- a/apps/cli/bin/index.js +++ b/apps/cli/bin/index.js @@ -1,3 +1,4 @@ +var _a, _b, _c, _d, _e, _f; import process$1 from "node:process"; import os from "node:os"; import tty from "node:tty"; @@ -499,20 +500,6 @@ function printErrorMessageWithReason(message, error2) { const errorMessage = "message" in error2 ? error2.message : error2; printStatusMessage(false, `${message}. Reason: ${errorMessage}`); } -function identity(x) { - return x; -} -function pipeFromArray(fns) { - if (fns.length === 0) { - return identity; - } - if (fns.length === 1) { - return fns[0]; - } - return function piped(input) { - return fns.reduce((prev, fn) => fn(prev), input); - }; -} function observable(subscribe) { const self = { subscribe(observer) { @@ -537,28 +524,28 @@ function observable(subscribe) { } teardownRef = subscribe({ next(value) { - var _a; + var _a2; if (isDone) { return; } - (_a = observer.next) == null ? void 0 : _a.call(observer, value); + (_a2 = observer.next) == null ? void 0 : _a2.call(observer, value); }, error(err) { - var _a; + var _a2; if (isDone) { return; } isDone = true; - (_a = observer.error) == null ? void 0 : _a.call(observer, err); + (_a2 = observer.error) == null ? void 0 : _a2.call(observer, err); unsubscribe(); }, complete() { - var _a; + var _a2; if (isDone) { return; } isDone = true; - (_a = observer.complete) == null ? void 0 : _a.call(observer); + (_a2 = observer.complete) == null ? void 0 : _a2.call(observer); unsubscribe(); } }); @@ -570,66 +557,13 @@ function observable(subscribe) { }; }, pipe(...operations) { - return pipeFromArray(operations)(self); + return operations.reduce(pipeReducer, self); } }; return self; } -function share(_opts) { - return (originalObserver) => { - let refCount = 0; - let subscription = null; - const observers = []; - function startIfNeeded() { - if (subscription) { - return; - } - subscription = originalObserver.subscribe({ - next(value) { - var _a; - for (const observer of observers) { - (_a = observer.next) == null ? void 0 : _a.call(observer, value); - } - }, - error(error2) { - var _a; - for (const observer of observers) { - (_a = observer.error) == null ? void 0 : _a.call(observer, error2); - } - }, - complete() { - var _a; - for (const observer of observers) { - (_a = observer.complete) == null ? void 0 : _a.call(observer); - } - } - }); - } - function resetIfNeeded() { - if (refCount === 0 && subscription) { - const _sub = subscription; - subscription = null; - _sub.unsubscribe(); - } - } - return { - subscribe(observer) { - refCount++; - observers.push(observer); - startIfNeeded(); - return { - unsubscribe() { - refCount--; - resetIfNeeded(); - const index = observers.findIndex((v) => v === observer); - if (index > -1) { - observers.splice(index, 1); - } - } - }; - } - }; - }; +function pipeReducer(prev, fn) { + return fn(prev); } class ObservableAbortError extends Error { constructor(message) { @@ -674,6 +608,60 @@ function observableToPromise(observable2) { abort }; } +function share(_opts) { + return (source) => { + let refCount = 0; + let subscription = null; + const observers = []; + function startIfNeeded() { + if (subscription) { + return; + } + subscription = source.subscribe({ + next(value) { + var _a2; + for (const observer of observers) { + (_a2 = observer.next) == null ? void 0 : _a2.call(observer, value); + } + }, + error(error2) { + var _a2; + for (const observer of observers) { + (_a2 = observer.error) == null ? void 0 : _a2.call(observer, error2); + } + }, + complete() { + var _a2; + for (const observer of observers) { + (_a2 = observer.complete) == null ? void 0 : _a2.call(observer); + } + } + }); + } + function resetIfNeeded() { + if (refCount === 0 && subscription) { + const _sub = subscription; + subscription = null; + _sub.unsubscribe(); + } + } + return observable((subscriber) => { + refCount++; + observers.push(subscriber); + startIfNeeded(); + return { + unsubscribe() { + refCount--; + resetIfNeeded(); + const index = observers.findIndex((v) => v === subscriber); + if (index > -1) { + observers.splice(index, 1); + } + } + }; + }); + }; +} function createChain(opts) { return observable((observer) => { function execute(index = 0, op = opts.op) { @@ -694,42 +682,6 @@ function createChain(opts) { return obs$.subscribe(observer); }); } -function invert(obj) { - const newObj = /* @__PURE__ */ Object.create(null); - for (const key in obj) { - const v = obj[key]; - newObj[v] = key; - } - return newObj; -} -const TRPC_ERROR_CODES_BY_KEY = { - /** - * Invalid JSON was received by the server. - * An error occurred on the server while parsing the JSON text. - */ - PARSE_ERROR: -32700, - /** - * The JSON sent is not a valid Request object. - */ - BAD_REQUEST: -32600, - // Internal JSON-RPC error - INTERNAL_SERVER_ERROR: -32603, - NOT_IMPLEMENTED: -32603, - // Implementation specific errors - UNAUTHORIZED: -32001, - FORBIDDEN: -32003, - NOT_FOUND: -32004, - METHOD_NOT_SUPPORTED: -32005, - TIMEOUT: -32008, - CONFLICT: -32009, - PRECONDITION_FAILED: -32012, - PAYLOAD_TOO_LARGE: -32013, - UNPROCESSABLE_CONTENT: -32022, - TOO_MANY_REQUESTS: -32029, - CLIENT_CLOSED_REQUEST: -32099 -}; -invert(TRPC_ERROR_CODES_BY_KEY); -invert(TRPC_ERROR_CODES_BY_KEY); const noop = () => { }; function createInnerProxy(callback, path2) { @@ -764,7 +716,7 @@ const createFlatProxy = (callback) => { } }); }; -function isObject$1(value) { +function isObject(value) { return !!value && !Array.isArray(value) && typeof value === "object"; } class UnknownCauseError extends Error { @@ -780,7 +732,7 @@ function getCauseFromUnknown(cause) { if (type !== "object") { return new Error(String(cause)); } - if (isObject$1(cause)) { + if (isObject(cause)) { const err = new UnknownCauseError(); for (const key in cause) { err[key] = cause[key]; @@ -789,12 +741,9 @@ function getCauseFromUnknown(cause) { } return void 0; } -function isObject(value) { - return !!value && !Array.isArray(value) && typeof value === "object"; -} -function transformResultInner(response, runtime) { +function transformResultInner(response, transformer) { if ("error" in response) { - const error2 = runtime.transformer.deserialize(response.error); + const error2 = transformer.deserialize(response.error); return { ok: false, error: { @@ -807,7 +756,7 @@ function transformResultInner(response, runtime) { ...response.result, ...(!response.result.type || response.result.type === "data") && { type: "data", - data: runtime.transformer.deserialize(response.result.data) + data: transformer.deserialize(response.result.data) } }; return { @@ -820,14 +769,14 @@ class TransformResultError extends Error { super("Unable to transform response from server"); } } -function transformResult(response, runtime) { +function transformResult(response, transformer) { let result; try { - result = transformResultInner(response, runtime); + result = transformResultInner(response, transformer); } catch (err) { throw new TransformResultError(); } - if (!result.ok && (!isObject(result.error.error) || typeof result.error.error.code !== "number")) { + if (!result.ok && (!isObject(result.error.error) || typeof result.error.error["code"] !== "number")) { throw new TransformResultError(); } if (result.ok && !isObject(result.result)) { @@ -835,6 +784,8 @@ function transformResult(response, runtime) { } return result; } +typeof window === "undefined" || "Deno" in window || // eslint-disable-next-line @typescript-eslint/dot-notation +((_b = (_a = globalThis.process) == null ? void 0 : _a.env) == null ? void 0 : _b["NODE_ENV"]) === "test" || !!((_d = (_c = globalThis.process) == null ? void 0 : _c.env) == null ? void 0 : _d["JEST_WORKER_ID"]) || !!((_f = (_e = globalThis.process) == null ? void 0 : _e.env) == null ? void 0 : _f["VITEST_WORKER_ID"]); function isTRPCClientError(cause) { return cause instanceof TRPCClientError || /** * @deprecated @@ -843,7 +794,7 @@ function isTRPCClientError(cause) { cause instanceof Error && cause.name === "TRPCClientError"; } function isTRPCErrorResponse(obj) { - return isObject(obj) && isObject(obj.error) && typeof obj.error.code === "number" && typeof obj.error.message === "string"; + return isObject(obj) && isObject(obj["error"]) && typeof obj["error"]["code"] === "number" && typeof obj["error"]["message"] === "string"; } class TRPCClientError extends Error { static from(_cause, opts = {}) { @@ -875,19 +826,131 @@ class TRPCClientError extends Error { }); } constructor(message, opts) { - var _a, _b; + var _a2, _b2; const cause = opts == null ? void 0 : opts.cause; super(message, { cause }); this.meta = opts == null ? void 0 : opts.meta; this.cause = cause; - this.shape = (_a = opts == null ? void 0 : opts.result) == null ? void 0 : _a.error; - this.data = (_b = opts == null ? void 0 : opts.result) == null ? void 0 : _b.error.data; + this.shape = (_a2 = opts == null ? void 0 : opts.result) == null ? void 0 : _a2.error; + this.data = (_b2 = opts == null ? void 0 : opts.result) == null ? void 0 : _b2.error.data; this.name = "TRPCClientError"; Object.setPrototypeOf(this, TRPCClientError.prototype); } } +class TRPCUntypedClient { + $request({ type, input, path: path2, context = {} }) { + const chain$ = createChain({ + links: this.links, + op: { + id: ++this.requestId, + type, + path: path2, + input, + context + } + }); + return chain$.pipe(share()); + } + requestAsPromise(opts) { + const req$ = this.$request(opts); + const { promise, abort } = observableToPromise(req$); + const abortablePromise = new Promise((resolve, reject) => { + var _a2; + (_a2 = opts.signal) == null ? void 0 : _a2.addEventListener("abort", abort); + promise.then((envelope) => { + resolve(envelope.result.data); + }).catch((err) => { + reject(TRPCClientError.from(err)); + }); + }); + return abortablePromise; + } + query(path2, input, opts) { + return this.requestAsPromise({ + type: "query", + path: path2, + input, + context: opts == null ? void 0 : opts.context, + signal: opts == null ? void 0 : opts.signal + }); + } + mutation(path2, input, opts) { + return this.requestAsPromise({ + type: "mutation", + path: path2, + input, + context: opts == null ? void 0 : opts.context, + signal: opts == null ? void 0 : opts.signal + }); + } + subscription(path2, input, opts) { + const observable$ = this.$request({ + type: "subscription", + path: path2, + input, + context: opts == null ? void 0 : opts.context + }); + return observable$.subscribe({ + next(envelope) { + var _a2, _b2, _c2; + if (envelope.result.type === "started") { + (_a2 = opts.onStarted) == null ? void 0 : _a2.call(opts); + } else if (envelope.result.type === "stopped") { + (_b2 = opts.onStopped) == null ? void 0 : _b2.call(opts); + } else { + (_c2 = opts.onData) == null ? void 0 : _c2.call(opts, envelope.result.data); + } + }, + error(err) { + var _a2; + (_a2 = opts.onError) == null ? void 0 : _a2.call(opts, err); + }, + complete() { + var _a2; + (_a2 = opts.onComplete) == null ? void 0 : _a2.call(opts); + } + }); + } + constructor(opts) { + this.requestId = 0; + this.runtime = {}; + this.links = opts.links.map((link) => link(this.runtime)); + } +} +const clientCallTypeMap = { + query: "query", + mutate: "mutation", + subscribe: "subscription" +}; +const clientCallTypeToProcedureType = (clientCallType) => { + return clientCallTypeMap[clientCallType]; +}; +function createTRPCClientProxy(client) { + return createFlatProxy((key) => { + if (client.hasOwnProperty(key)) { + return client[key]; + } + if (key === "__untypedClient") { + return client; + } + return createRecursiveProxy(({ path: path2, args }) => { + const pathCopy = [ + key, + ...path2 + ]; + const procedureType = clientCallTypeToProcedureType(pathCopy.pop()); + const fullPath = pathCopy.join("."); + return client[procedureType](fullPath, ...args); + }); + }); +} +function createTRPCClient(opts) { + const client = new TRPCUntypedClient(opts); + const proxy = createTRPCClientProxy(client); + return proxy; +} const isFunction = (fn) => typeof fn === "function"; function getFetch(customFetchImpl) { if (customFetchImpl) { @@ -901,6 +964,128 @@ function getFetch(customFetchImpl) { } throw new Error("No fetch implementation found"); } +const throwFatalError = () => { + throw new Error("Something went wrong. Please submit an issue at https://github.com/trpc/trpc/issues/new"); +}; +function dataLoader(batchLoader) { + let pendingItems = null; + let dispatchTimer = null; + const destroyTimerAndPendingItems = () => { + clearTimeout(dispatchTimer); + dispatchTimer = null; + pendingItems = null; + }; + function groupItems(items) { + var _a2, _b2; + const groupedItems = [ + [] + ]; + let index = 0; + while (true) { + const item = items[index]; + if (!item) { + break; + } + const lastGroup = groupedItems[groupedItems.length - 1]; + if (item.aborted) { + (_a2 = item.reject) == null ? void 0 : _a2.call(item, new Error("Aborted")); + index++; + continue; + } + const isValid = batchLoader.validate(lastGroup.concat(item).map((it) => it.key)); + if (isValid) { + lastGroup.push(item); + index++; + continue; + } + if (lastGroup.length === 0) { + (_b2 = item.reject) == null ? void 0 : _b2.call(item, new Error("Input is too big for a single dispatch")); + index++; + continue; + } + groupedItems.push([]); + } + return groupedItems; + } + function dispatch() { + const groupedItems = groupItems(pendingItems); + destroyTimerAndPendingItems(); + for (const items of groupedItems) { + if (!items.length) { + continue; + } + const batch = { + items, + cancel: throwFatalError + }; + for (const item of items) { + item.batch = batch; + } + const unitResolver = (index, value) => { + var _a2; + const item = batch.items[index]; + (_a2 = item.resolve) == null ? void 0 : _a2.call(item, value); + item.batch = null; + item.reject = null; + item.resolve = null; + }; + const { promise, cancel } = batchLoader.fetch(batch.items.map((_item) => _item.key), unitResolver); + batch.cancel = cancel; + promise.then((result) => { + var _a2; + for (let i = 0; i < result.length; i++) { + const value = result[i]; + unitResolver(i, value); + } + for (const item of batch.items) { + (_a2 = item.reject) == null ? void 0 : _a2.call(item, new Error("Missing result")); + item.batch = null; + } + }).catch((cause) => { + var _a2; + for (const item of batch.items) { + (_a2 = item.reject) == null ? void 0 : _a2.call(item, cause); + item.batch = null; + } + }); + } + } + function load(key) { + const item = { + aborted: false, + key, + batch: null, + resolve: throwFatalError, + reject: throwFatalError + }; + const promise = new Promise((resolve, reject) => { + item.reject = reject; + item.resolve = resolve; + if (!pendingItems) { + pendingItems = []; + } + pendingItems.push(item); + }); + if (!dispatchTimer) { + dispatchTimer = setTimeout(dispatch); + } + const cancel = () => { + var _a2; + item.aborted = true; + if ((_a2 = item.batch) == null ? void 0 : _a2.items.every((item2) => item2.aborted)) { + item.batch.cancel(); + item.batch = null; + } + }; + return { + promise, + cancel + }; + } + return { + load + }; +} function getAbortController(customAbortControllerImpl) { if (customAbortControllerImpl) { return customAbortControllerImpl; @@ -913,11 +1098,35 @@ function getAbortController(customAbortControllerImpl) { } return null; } +function getTransformer(transformer) { + const _transformer = transformer; + if (!_transformer) { + return { + input: { + serialize: (data) => data, + deserialize: (data) => data + }, + output: { + serialize: (data) => data, + deserialize: (data) => data + } + }; + } + if ("input" in _transformer) { + return _transformer; + } + return { + input: _transformer, + output: _transformer + }; +} function resolveHTTPLinkOptions(opts) { return { url: opts.url.toString().replace(/\/$/, ""), fetch: opts.fetch, - AbortController: getAbortController(opts.AbortController) + AbortController: getAbortController(opts.AbortController), + transformer: getTransformer(opts.transformer), + methodOverride: opts.methodOverride }; } function arrayToDict(array) { @@ -933,7 +1142,7 @@ const METHOD = { mutation: "POST" }; function getInput(opts) { - return "input" in opts ? opts.runtime.transformer.serialize(opts.input) : arrayToDict(opts.inputs.map((_input) => opts.runtime.transformer.serialize(_input))); + return "input" in opts ? opts.transformer.input.serialize(opts.input) : arrayToDict(opts.inputs.map((_input) => opts.transformer.input.serialize(_input))); } const getUrl = (opts) => { let url = opts.url + "/" + opts.path; @@ -943,7 +1152,7 @@ const getUrl = (opts) => { } if (opts.type === "query") { const input = getInput(opts); - if (input !== void 0) { + if (input !== void 0 && opts.methodOverride !== "POST") { queryParts.push(`input=${encodeURIComponent(JSON.stringify(input))}`); } } @@ -953,7 +1162,7 @@ const getUrl = (opts) => { return url; }; const getBody = (opts) => { - if (opts.type === "query") { + if (opts.type === "query" && opts.methodOverride !== "POST") { return void 0; } const input = getInput(opts); @@ -971,7 +1180,13 @@ async function fetchHTTPResponse(opts, ac) { const url = opts.getUrl(opts); const body = opts.getBody(opts); const { type } = opts; - const resolvedHeaders = await opts.headers(); + const resolvedHeaders = await (async () => { + const heads = await opts.headers(); + if (Symbol.iterator in heads) { + return Object.fromEntries(heads); + } + return heads; + })(); /* istanbul ignore if -- @preserve */ if (type === "subscription") { throw new Error("Subscriptions should use wsLink"); @@ -986,7 +1201,7 @@ async function fetchHTTPResponse(opts, ac) { ...resolvedHeaders }; return getFetch(opts.fetch)(url, { - method: METHOD[type], + method: opts.methodOverride ?? METHOD[type], signal: ac == null ? void 0 : ac.signal, body, headers @@ -1024,128 +1239,6 @@ function httpRequest(opts) { cancel }; } -const throwFatalError = () => { - throw new Error("Something went wrong. Please submit an issue at https://github.com/trpc/trpc/issues/new"); -}; -function dataLoader(batchLoader) { - let pendingItems = null; - let dispatchTimer = null; - const destroyTimerAndPendingItems = () => { - clearTimeout(dispatchTimer); - dispatchTimer = null; - pendingItems = null; - }; - function groupItems(items) { - var _a, _b; - const groupedItems = [ - [] - ]; - let index = 0; - while (true) { - const item = items[index]; - if (!item) { - break; - } - const lastGroup = groupedItems[groupedItems.length - 1]; - if (item.aborted) { - (_a = item.reject) == null ? void 0 : _a.call(item, new Error("Aborted")); - index++; - continue; - } - const isValid = batchLoader.validate(lastGroup.concat(item).map((it) => it.key)); - if (isValid) { - lastGroup.push(item); - index++; - continue; - } - if (lastGroup.length === 0) { - (_b = item.reject) == null ? void 0 : _b.call(item, new Error("Input is too big for a single dispatch")); - index++; - continue; - } - groupedItems.push([]); - } - return groupedItems; - } - function dispatch() { - const groupedItems = groupItems(pendingItems); - destroyTimerAndPendingItems(); - for (const items of groupedItems) { - if (!items.length) { - continue; - } - const batch = { - items, - cancel: throwFatalError - }; - for (const item of items) { - item.batch = batch; - } - const unitResolver = (index, value) => { - var _a; - const item = batch.items[index]; - (_a = item.resolve) == null ? void 0 : _a.call(item, value); - item.batch = null; - item.reject = null; - item.resolve = null; - }; - const { promise, cancel } = batchLoader.fetch(batch.items.map((_item) => _item.key), unitResolver); - batch.cancel = cancel; - promise.then((result) => { - var _a; - for (let i = 0; i < result.length; i++) { - const value = result[i]; - unitResolver(i, value); - } - for (const item of batch.items) { - (_a = item.reject) == null ? void 0 : _a.call(item, new Error("Missing result")); - item.batch = null; - } - }).catch((cause) => { - var _a; - for (const item of batch.items) { - (_a = item.reject) == null ? void 0 : _a.call(item, cause); - item.batch = null; - } - }); - } - } - function load(key) { - const item = { - aborted: false, - key, - batch: null, - resolve: throwFatalError, - reject: throwFatalError - }; - const promise = new Promise((resolve, reject) => { - item.reject = reject; - item.resolve = resolve; - if (!pendingItems) { - pendingItems = []; - } - pendingItems.push(item); - }); - if (!dispatchTimer) { - dispatchTimer = setTimeout(dispatch); - } - const cancel = () => { - var _a; - item.aborted = true; - if ((_a = item.batch) == null ? void 0 : _a.items.every((item2) => item2.aborted)) { - item.batch.cancel(); - item.batch = null; - } - }; - return { - promise, - cancel - }; - } - return { - load - }; -} function createHTTPBatchLink(requester) { return function httpBatchLink2(opts) { const resolvedOpts = resolveHTTPLinkOptions(opts); @@ -1160,7 +1253,6 @@ function createHTTPBatchLink(requester) { const inputs = batchOps.map((op) => op.input); const url = getUrl({ ...resolvedOpts, - runtime, type, path: path2, inputs @@ -1193,7 +1285,7 @@ function createHTTPBatchLink(requester) { let _res = void 0; promise.then((res) => { _res = res; - const transformed = transformResult(res.json, runtime); + const transformed = transformResult(res.json, resolvedOpts.transformer.output); if (!transformed.ok) { observer.error(TRPCClientError.from(transformed.error, { meta: res.meta @@ -1252,149 +1344,9 @@ const batchRequester = (requesterOpts) => { }; }; const httpBatchLink = createHTTPBatchLink(batchRequester); -class TRPCUntypedClient { - $request({ type, input, path: path2, context = {} }) { - const chain$ = createChain({ - links: this.links, - op: { - id: ++this.requestId, - type, - path: path2, - input, - context - } - }); - return chain$.pipe(share()); - } - requestAsPromise(opts) { - const req$ = this.$request(opts); - const { promise, abort } = observableToPromise(req$); - const abortablePromise = new Promise((resolve, reject) => { - var _a; - (_a = opts.signal) == null ? void 0 : _a.addEventListener("abort", abort); - promise.then((envelope) => { - resolve(envelope.result.data); - }).catch((err) => { - reject(TRPCClientError.from(err)); - }); - }); - return abortablePromise; - } - query(path2, input, opts) { - return this.requestAsPromise({ - type: "query", - path: path2, - input, - context: opts == null ? void 0 : opts.context, - signal: opts == null ? void 0 : opts.signal - }); - } - mutation(path2, input, opts) { - return this.requestAsPromise({ - type: "mutation", - path: path2, - input, - context: opts == null ? void 0 : opts.context, - signal: opts == null ? void 0 : opts.signal - }); - } - subscription(path2, input, opts) { - const observable$ = this.$request({ - type: "subscription", - path: path2, - input, - context: opts == null ? void 0 : opts.context - }); - return observable$.subscribe({ - next(envelope) { - var _a, _b, _c; - if (envelope.result.type === "started") { - (_a = opts.onStarted) == null ? void 0 : _a.call(opts); - } else if (envelope.result.type === "stopped") { - (_b = opts.onStopped) == null ? void 0 : _b.call(opts); - } else { - (_c = opts.onData) == null ? void 0 : _c.call(opts, envelope.result.data); - } - }, - error(err) { - var _a; - (_a = opts.onError) == null ? void 0 : _a.call(opts, err); - }, - complete() { - var _a; - (_a = opts.onComplete) == null ? void 0 : _a.call(opts); - } - }); - } - constructor(opts) { - this.requestId = 0; - const combinedTransformer = (() => { - const transformer = opts.transformer; - if (!transformer) { - return { - input: { - serialize: (data) => data, - deserialize: (data) => data - }, - output: { - serialize: (data) => data, - deserialize: (data) => data - } - }; - } - if ("input" in transformer) { - return opts.transformer; - } - return { - input: transformer, - output: transformer - }; - })(); - this.runtime = { - transformer: { - serialize: (data) => combinedTransformer.input.serialize(data), - deserialize: (data) => combinedTransformer.output.deserialize(data) - }, - combinedTransformer - }; - this.links = opts.links.map((link) => link(this.runtime)); - } -} -const clientCallTypeMap = { - query: "query", - mutate: "mutation", - subscribe: "subscription" -}; -const clientCallTypeToProcedureType = (clientCallType) => { - return clientCallTypeMap[clientCallType]; -}; -function createTRPCClientProxy(client) { - return createFlatProxy((key) => { - if (client.hasOwnProperty(key)) { - return client[key]; - } - if (key === "__untypedClient") { - return client; - } - return createRecursiveProxy(({ path: path2, args }) => { - const pathCopy = [ - key, - ...path2 - ]; - const procedureType = clientCallTypeToProcedureType(pathCopy.pop()); - const fullPath = pathCopy.join("."); - return client[procedureType](fullPath, ...args); - }); - }); -} -function createTRPCProxyClient(opts) { - const client = new TRPCUntypedClient(opts); - const proxy = createTRPCClientProxy(client); - return proxy; -} function getAPIClient() { const globals = getGlobalOptions(); - return createTRPCProxyClient({ + return createTRPCClient({ links: [ httpBatchLink({ url: `${globals.serverAddr}/api/trpc`, @@ -3141,13 +3093,13 @@ Expecting one of '${allowedValues.join("', '")}'`); * @private */ _prepareUserArgs(argv, parseOptions) { - var _a; + var _a2; if (argv !== void 0 && !Array.isArray(argv)) { throw new Error("first parameter to parse must be array or undefined"); } parseOptions = parseOptions || {}; if (argv === void 0 && parseOptions.from === void 0) { - if ((_a = process.versions) == null ? void 0 : _a.electron) { + if ((_a2 = process.versions) == null ? void 0 : _a2.electron) { parseOptions.from = "electron"; } const execArgv = process.execArgv ?? []; @@ -3386,7 +3338,7 @@ Expecting one of '${allowedValues.join("', '")}'`); * @private */ _dispatchHelpCommand(subcommandName) { - var _a, _b; + var _a2, _b2; if (!subcommandName) { this.help(); } @@ -3397,7 +3349,7 @@ Expecting one of '${allowedValues.join("', '")}'`); return this._dispatchSubcommand( subcommandName, [], - [((_a = this._getHelpOption()) == null ? void 0 : _a.long) ?? ((_b = this._getHelpOption()) == null ? void 0 : _b.short) ?? "--help"] + [((_a2 = this._getHelpOption()) == null ? void 0 : _a2.long) ?? ((_b2 = this._getHelpOption()) == null ? void 0 : _b2.short) ?? "--help"] ); } /** @@ -4059,7 +4011,7 @@ Expecting one of '${allowedValues.join("', '")}'`); * @return {(string|Command)} */ alias(alias) { - var _a; + var _a2; if (alias === void 0) return this._aliases[0]; let command2 = this; if (this.commands.length !== 0 && this.commands[this.commands.length - 1]._executableHandler) { @@ -4067,7 +4019,7 @@ Expecting one of '${allowedValues.join("', '")}'`); } if (alias === command2._name) throw new Error("Command alias can't be the same as its name"); - const matchingCommand = (_a = this.parent) == null ? void 0 : _a._findCommand(alias); + const matchingCommand = (_a2 = this.parent) == null ? void 0 : _a2._findCommand(alias); if (matchingCommand) { const existingCmd = [matchingCommand.name()].concat(matchingCommand.aliases()).join("|"); throw new Error( @@ -4191,7 +4143,7 @@ Expecting one of '${allowedValues.join("', '")}'`); * @param {{ error: boolean } | Function} [contextOptions] - pass {error:true} to write to stderr instead of stdout */ outputHelp(contextOptions) { - var _a; + var _a2; let deprecatedCallback; if (typeof contextOptions === "function") { deprecatedCallback = contextOptions; @@ -4208,7 +4160,7 @@ Expecting one of '${allowedValues.join("', '")}'`); } } context.write(helpInformation); - if ((_a = this._getHelpOption()) == null ? void 0 : _a.long) { + if ((_a2 = this._getHelpOption()) == null ? void 0 : _a2.long) { this.emit(this._getHelpOption().long); } this.emit("afterHelp", context); diff --git a/apps/cli/package.json b/apps/cli/package.json index 04c33b9..4fc8ecb 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -26,8 +26,8 @@ "@lifetracker/eslint-config": "workspace:*", "@lifetracker/typescript-config": "workspace:*", "@lifetracker/ui": "workspace:*", - "@trpc/client": "^10.45.2", - "@trpc/server": "^10.45.2", + "@trpc/client": "^11.0.0-next-beta.308", + "@trpc/server": "^11.0.0-next-beta.308", "chalk": "^5.3.0", "commander": "^12.1.0", "superjson": "^2.2.1", diff --git a/apps/cli/src/lib/trpc.ts b/apps/cli/src/lib/trpc.ts index 1b2e17d..f172d73 100644 --- a/apps/cli/src/lib/trpc.ts +++ b/apps/cli/src/lib/trpc.ts @@ -1,6 +1,6 @@ import { getGlobalOptions } from "@/lib/globals"; import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; - +import superjson from "superjson"; import type { AppRouter } from "@lifetracker/trpc/routers/_app"; export function getAPIClient() { @@ -15,7 +15,7 @@ export function getAPIClient() { authorization: `Bearer ${globals.apiKey}`, }; }, - }), + },), ], }); } diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js deleted file mode 100644 index 51c309b..0000000 --- a/apps/web/.eslintrc.js +++ /dev/null @@ -1,9 +0,0 @@ -/** @type {import("eslint").Linter.Config} */ -module.exports = { - root: true, - extends: ["@lifetracker/eslint-config/next.js"], - parser: "@typescript-eslint/parser", - parserOptions: { - project: true, - }, -}; diff --git a/apps/web/.gitignore b/apps/web/.gitignore deleted file mode 100644 index f886745..0000000 --- a/apps/web/.gitignore +++ /dev/null @@ -1,36 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js -.yarn/install-state.gz - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# env files (can opt-in for commiting if needed) -.env* - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts diff --git a/apps/web/README.md b/apps/web/README.md index a98bfa8..c403366 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,4 +1,4 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app). +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). ## Getting Started @@ -18,7 +18,7 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Inter, a custom Google Font. +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. ## Learn More @@ -27,10 +27,10 @@ To learn more about Next.js, take a look at the following resources: - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! ## Deploy on Vercel The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/apps/web/app/api/assets/[assetId]/route.ts b/apps/web/app/api/assets/[assetId]/route.ts new file mode 100644 index 0000000..20bd30c --- /dev/null +++ b/apps/web/app/api/assets/[assetId]/route.ts @@ -0,0 +1,56 @@ +import { createContextFromRequest } from "@/server/api/client"; +import { and, eq } from "drizzle-orm"; + +import { assets } from "@lifetracker/db/schema"; +import { readAsset } from "@lifetracker/shared/assetdb"; + +export const dynamic = "force-dynamic"; +export async function GET( + request: Request, + { params }: { params: { assetId: string } }, +) { + const ctx = await createContextFromRequest(request); + if (!ctx.user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const assetDb = await ctx.db.query.assets.findFirst({ + where: and(eq(assets.id, params.assetId), eq(assets.userId, ctx.user.id)), + }); + + if (!assetDb) { + return Response.json({ error: "Asset not found" }, { status: 404 }); + } + + const { asset, metadata } = await readAsset({ + userId: ctx.user.id, + assetId: params.assetId, + }); + + const range = request.headers.get("Range"); + if (range) { + const parts = range.replace(/bytes=/, "").split("-"); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : asset.length - 1; + + // TODO: Don't read the whole asset into memory in the first place + const chunk = asset.subarray(start, end + 1); + return new Response(chunk, { + status: 206, // Partial Content + headers: { + "Content-Range": `bytes ${start}-${end}/${asset.length}`, + "Accept-Ranges": "bytes", + "Content-Length": chunk.length.toString(), + "Content-type": metadata.contentType, + }, + }); + } else { + return new Response(asset, { + status: 200, + headers: { + "Content-Length": asset.length.toString(), + "Content-type": metadata.contentType, + }, + }); + } +} diff --git a/apps/web/app/api/assets/route.ts b/apps/web/app/api/assets/route.ts new file mode 100644 index 0000000..28f8142 --- /dev/null +++ b/apps/web/app/api/assets/route.ts @@ -0,0 +1,77 @@ +import { createContextFromRequest } from "@/server/api/client"; +import { TRPCError } from "@trpc/server"; + +import type { ZUploadResponse } from "@lifetracker/shared/types/uploads"; +import { assets, AssetTypes } from "@lifetracker/db/schema"; +import { + newAssetId, + saveAsset, + SUPPORTED_UPLOAD_ASSET_TYPES, +} from "@lifetracker/shared/assetdb"; +import serverConfig from "@lifetracker/shared/config"; + +const MAX_UPLOAD_SIZE_BYTES = serverConfig.maxAssetSizeMb * 1024 * 1024; + +export const dynamic = "force-dynamic"; +export async function POST(request: Request) { + const ctx = await createContextFromRequest(request); + if (!ctx.user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + if (serverConfig.demoMode) { + throw new TRPCError({ + message: "Mutations are not allowed in demo mode", + code: "FORBIDDEN", + }); + } + const formData = await request.formData(); + const data = formData.get("file") ?? formData.get("image"); + let buffer; + let contentType; + if (data instanceof File) { + contentType = data.type; + if (!SUPPORTED_UPLOAD_ASSET_TYPES.has(contentType)) { + return Response.json( + { error: "Unsupported asset type" }, + { status: 400 }, + ); + } + if (data.size > MAX_UPLOAD_SIZE_BYTES) { + return Response.json({ error: "Asset is too big" }, { status: 413 }); + } + buffer = Buffer.from(await data.arrayBuffer()); + } else { + return Response.json({ error: "Bad request" }, { status: 400 }); + } + + const fileName = data.name; + const [assetDb] = await ctx.db + .insert(assets) + .values({ + id: newAssetId(), + // Initially, uploads are uploaded for unknown purpose + // And without an attached bookmark. + assetType: AssetTypes.UNKNOWN, + bookmarkId: null, + userId: ctx.user.id, + contentType, + size: data.size, + fileName, + }) + .returning(); + const assetId = assetDb.id; + + await saveAsset({ + userId: ctx.user.id, + assetId, + metadata: { contentType, fileName }, + asset: buffer, + }); + + return Response.json({ + assetId, + contentType, + size: buffer.byteLength, + fileName, + } satisfies ZUploadResponse); +} diff --git a/apps/web/app/api/auth/[...nextauth]/route.tsx b/apps/web/app/api/auth/[...nextauth]/route.tsx new file mode 100644 index 0000000..2f7f1cb --- /dev/null +++ b/apps/web/app/api/auth/[...nextauth]/route.tsx @@ -0,0 +1,3 @@ +import { authHandler } from "@/server/auth"; + +export { authHandler as GET, authHandler as POST }; diff --git a/apps/web/app/api/bookmarks/export/route.tsx b/apps/web/app/api/bookmarks/export/route.tsx new file mode 100644 index 0000000..7ae46c5 --- /dev/null +++ b/apps/web/app/api/bookmarks/export/route.tsx @@ -0,0 +1,40 @@ +import { toExportFormat, zExportSchema } from "@/lib/exportBookmarks"; +import { api, createContextFromRequest } from "@/server/api/client"; +import { z } from "zod"; + +import { MAX_NUM_BOOKMARKS_PER_PAGE } from "@hoarder/shared/types/bookmarks"; + +export const dynamic = "force-dynamic"; +export async function GET(request: Request) { + const ctx = await createContextFromRequest(request); + if (!ctx.user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const req = { + limit: MAX_NUM_BOOKMARKS_PER_PAGE, + useCursorV2: true, + }; + + let resp = await api.bookmarks.getBookmarks(req); + let results = resp.bookmarks.map(toExportFormat); + + while (resp.nextCursor) { + resp = await api.bookmarks.getBookmarks({ + ...request, + cursor: resp.nextCursor, + }); + results = [...results, ...resp.bookmarks.map(toExportFormat)]; + } + + const exportData: z.infer = { + bookmarks: results.filter((b) => b.content !== null), + }; + + return new Response(JSON.stringify(exportData), { + status: 200, + headers: { + "Content-type": "application/json", + "Content-disposition": `attachment; filename="hoarder-export-${new Date().toISOString()}.json"`, + }, + }); +} diff --git a/apps/web/app/api/health/route.ts b/apps/web/app/api/health/route.ts index 7f0fd5c..8e5326c 100644 --- a/apps/web/app/api/health/route.ts +++ b/apps/web/app/api/health/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; export const GET = async (_req: NextRequest) => { - return NextResponse.json({ - status: "ok", - message: "Web app is working", - }); + return NextResponse.json({ + status: "ok", + message: "Web app is working", + }); }; diff --git a/apps/web/app/api/trpc/[trpc]/route.ts b/apps/web/app/api/trpc/[trpc]/route.ts new file mode 100644 index 0000000..ed97543 --- /dev/null +++ b/apps/web/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,21 @@ +import { createContextFromRequest } from "@/server/api/client"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { appRouter } from "@lifetracker/trpc/routers/_app"; + +const handler = (req: Request) => + fetchRequestHandler({ + endpoint: "/api/trpc", + req, + router: appRouter, + onError: ({ path, error }) => { + if (process.env.NODE_ENV === "development") { + console.error(`❌ tRPC failed on ${path}`); + } + console.error(error); + }, + + createContext: async (opts) => { + return await createContextFromRequest(opts.req); + }, + }); +export { handler as GET, handler as POST }; diff --git a/apps/web/app/api/trpc/route.ts b/apps/web/app/api/trpc/route.ts deleted file mode 100644 index 94abda4..0000000 --- a/apps/web/app/api/trpc/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createContextFromRequest } from "@/server/api/client"; -import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; - -import { appRouter } from "@lifetracker/trpc/routers/_app"; - -const handler = (req: Request) => - fetchRequestHandler({ - endpoint: "/api/trpc", - req, - router: appRouter, - onError: ({ path, error }) => { - if (process.env.NODE_ENV === "development") { - console.error(`❌ tRPC failed on ${path}`); - } - console.error(error); - }, - - createContext: async (opts) => { - return await createContextFromRequest(opts.req); - }, - }); -export { handler as GET, handler as POST }; diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/lists/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/lists/route.ts new file mode 100644 index 0000000..ad3052c --- /dev/null +++ b/apps/web/app/api/v1/bookmarks/[bookmarkId]/lists/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; +import { buildHandler } from "@/app/api/v1/utils/handler"; + +export const dynamic = "force-dynamic"; + +export const GET = ( + req: NextRequest, + params: { params: { bookmarkId: string } }, +) => + buildHandler({ + req, + handler: async ({ api }) => { + const resp = await api.lists.getListsOfBookmark({ + bookmarkId: params.params.bookmarkId, + }); + return { status: 200, resp }; + }, + }); diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/route.ts new file mode 100644 index 0000000..8fe4d9f --- /dev/null +++ b/apps/web/app/api/v1/bookmarks/[bookmarkId]/route.ts @@ -0,0 +1,50 @@ +import { NextRequest } from "next/server"; +import { buildHandler } from "@/app/api/v1/utils/handler"; + +import { zUpdateBookmarksRequestSchema } from "@hoarder/shared/types/bookmarks"; + +export const dynamic = "force-dynamic"; + +export const GET = ( + req: NextRequest, + { params }: { params: { bookmarkId: string } }, +) => + buildHandler({ + req, + handler: async ({ api }) => { + const bookmark = await api.bookmarks.getBookmark({ + bookmarkId: params.bookmarkId, + }); + return { status: 200, resp: bookmark }; + }, + }); + +export const PATCH = ( + req: NextRequest, + { params }: { params: { bookmarkId: string } }, +) => + buildHandler({ + req, + bodySchema: zUpdateBookmarksRequestSchema.omit({ bookmarkId: true }), + handler: async ({ api, body }) => { + const bookmark = await api.bookmarks.updateBookmark({ + bookmarkId: params.bookmarkId, + ...body!, + }); + return { status: 200, resp: bookmark }; + }, + }); + +export const DELETE = ( + req: NextRequest, + { params }: { params: { bookmarkId: string } }, +) => + buildHandler({ + req, + handler: async ({ api }) => { + await api.bookmarks.deleteBookmark({ + bookmarkId: params.bookmarkId, + }); + return { status: 204 }; + }, + }); diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/tags/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/tags/route.ts new file mode 100644 index 0000000..df46461 --- /dev/null +++ b/apps/web/app/api/v1/bookmarks/[bookmarkId]/tags/route.ts @@ -0,0 +1,45 @@ +import { NextRequest } from "next/server"; +import { buildHandler } from "@/app/api/v1/utils/handler"; +import { z } from "zod"; + +import { zManipulatedTagSchema } from "@hoarder/shared/types/bookmarks"; + +export const dynamic = "force-dynamic"; + +export const POST = ( + req: NextRequest, + params: { params: { bookmarkId: string } }, +) => + buildHandler({ + req, + bodySchema: z.object({ + tags: z.array(zManipulatedTagSchema), + }), + handler: async ({ api, body }) => { + const resp = await api.bookmarks.updateTags({ + bookmarkId: params.params.bookmarkId, + attach: body!.tags, + detach: [], + }); + return { status: 200, resp: { attached: resp.attached } }; + }, + }); + +export const DELETE = ( + req: NextRequest, + params: { params: { bookmarkId: string } }, +) => + buildHandler({ + req, + bodySchema: z.object({ + tags: z.array(zManipulatedTagSchema), + }), + handler: async ({ api, body }) => { + const resp = await api.bookmarks.updateTags({ + bookmarkId: params.params.bookmarkId, + detach: body!.tags, + attach: [], + }); + return { status: 200, resp: { detached: resp.detached } }; + }, + }); diff --git a/apps/web/app/api/v1/bookmarks/route.ts b/apps/web/app/api/v1/bookmarks/route.ts new file mode 100644 index 0000000..1342f07 --- /dev/null +++ b/apps/web/app/api/v1/bookmarks/route.ts @@ -0,0 +1,37 @@ +import { NextRequest } from "next/server"; +import { z } from "zod"; + +import { zNewBookmarkRequestSchema } from "@hoarder/shared/types/bookmarks"; + +import { buildHandler } from "../utils/handler"; +import { adaptPagination, zPagination } from "../utils/pagination"; +import { zStringBool } from "../utils/types"; + +export const dynamic = "force-dynamic"; + +export const GET = (req: NextRequest) => + buildHandler({ + req, + searchParamsSchema: z + .object({ + favourited: zStringBool.optional(), + archived: zStringBool.optional(), + }) + .and(zPagination), + handler: async ({ api, searchParams }) => { + const bookmarks = await api.bookmarks.getBookmarks({ + ...searchParams, + }); + return { status: 200, resp: adaptPagination(bookmarks) }; + }, + }); + +export const POST = (req: NextRequest) => + buildHandler({ + req, + bodySchema: zNewBookmarkRequestSchema, + handler: async ({ api, body }) => { + const bookmark = await api.bookmarks.createBookmark(body!); + return { status: 201, resp: bookmark }; + }, + }); diff --git a/apps/web/app/api/v1/lists/[listId]/bookmarks/[bookmarkId]/route.ts b/apps/web/app/api/v1/lists/[listId]/bookmarks/[bookmarkId]/route.ts new file mode 100644 index 0000000..6efe205 --- /dev/null +++ b/apps/web/app/api/v1/lists/[listId]/bookmarks/[bookmarkId]/route.ts @@ -0,0 +1,35 @@ +import { NextRequest } from "next/server"; +import { buildHandler } from "@/app/api/v1/utils/handler"; + +export const dynamic = "force-dynamic"; + +export const PUT = ( + req: NextRequest, + { params }: { params: { listId: string; bookmarkId: string } }, +) => + buildHandler({ + req, + handler: async ({ api }) => { + // TODO: PUT is supposed to be idempotent, but we currently fail if the bookmark is already in the list. + await api.lists.addToList({ + listId: params.listId, + bookmarkId: params.bookmarkId, + }); + return { status: 204 }; + }, + }); + +export const DELETE = ( + req: NextRequest, + { params }: { params: { listId: string; bookmarkId: string } }, +) => + buildHandler({ + req, + handler: async ({ api }) => { + await api.lists.removeFromList({ + listId: params.listId, + bookmarkId: params.bookmarkId, + }); + return { status: 204 }; + }, + }); diff --git a/apps/web/app/api/v1/lists/[listId]/bookmarks/route.ts b/apps/web/app/api/v1/lists/[listId]/bookmarks/route.ts new file mode 100644 index 0000000..72d4aa5 --- /dev/null +++ b/apps/web/app/api/v1/lists/[listId]/bookmarks/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; +import { buildHandler } from "@/app/api/v1/utils/handler"; +import { adaptPagination, zPagination } from "@/app/api/v1/utils/pagination"; + +export const dynamic = "force-dynamic"; + +export const GET = (req: NextRequest, params: { params: { listId: string } }) => + buildHandler({ + req, + searchParamsSchema: zPagination, + handler: async ({ api, searchParams }) => { + const bookmarks = await api.bookmarks.getBookmarks({ + listId: params.params.listId, + ...searchParams, + }); + return { status: 200, resp: adaptPagination(bookmarks) }; + }, + }); diff --git a/apps/web/app/api/v1/lists/[listId]/route.ts b/apps/web/app/api/v1/lists/[listId]/route.ts new file mode 100644 index 0000000..69c99fd --- /dev/null +++ b/apps/web/app/api/v1/lists/[listId]/route.ts @@ -0,0 +1,55 @@ +import { NextRequest } from "next/server"; +import { buildHandler } from "@/app/api/v1/utils/handler"; + +import { zNewBookmarkListSchema } from "@hoarder/shared/types/lists"; + +export const dynamic = "force-dynamic"; + +export const GET = ( + req: NextRequest, + { params }: { params: { listId: string } }, +) => + buildHandler({ + req, + handler: async ({ api }) => { + const list = await api.lists.get({ + listId: params.listId, + }); + return { + status: 200, + resp: list, + }; + }, + }); + +export const PATCH = ( + req: NextRequest, + { params }: { params: { listId: string } }, +) => + buildHandler({ + req, + bodySchema: zNewBookmarkListSchema.partial(), + handler: async ({ api, body }) => { + const list = await api.lists.edit({ + listId: params.listId, + ...body!, + }); + return { status: 200, resp: list }; + }, + }); + +export const DELETE = ( + req: NextRequest, + { params }: { params: { listId: string } }, +) => + buildHandler({ + req, + handler: async ({ api }) => { + await api.lists.delete({ + listId: params.listId, + }); + return { + status: 204, + }; + }, + }); diff --git a/apps/web/app/api/v1/lists/route.ts b/apps/web/app/api/v1/lists/route.ts new file mode 100644 index 0000000..724fadf --- /dev/null +++ b/apps/web/app/api/v1/lists/route.ts @@ -0,0 +1,26 @@ +import { NextRequest } from "next/server"; + +import { zNewBookmarkListSchema } from "@hoarder/shared/types/lists"; + +import { buildHandler } from "../utils/handler"; + +export const dynamic = "force-dynamic"; + +export const GET = (req: NextRequest) => + buildHandler({ + req, + handler: async ({ api }) => { + const lists = await api.lists.list(); + return { status: 200, resp: lists }; + }, + }); + +export const POST = (req: NextRequest) => + buildHandler({ + req, + bodySchema: zNewBookmarkListSchema, + handler: async ({ api, body }) => { + const list = await api.lists.create(body!); + return { status: 201, resp: list }; + }, + }); diff --git a/apps/web/app/api/v1/tags/[tagId]/bookmarks/route.ts b/apps/web/app/api/v1/tags/[tagId]/bookmarks/route.ts new file mode 100644 index 0000000..98133ec --- /dev/null +++ b/apps/web/app/api/v1/tags/[tagId]/bookmarks/route.ts @@ -0,0 +1,25 @@ +import { NextRequest } from "next/server"; +import { buildHandler } from "@/app/api/v1/utils/handler"; +import { adaptPagination, zPagination } from "@/app/api/v1/utils/pagination"; + +export const dynamic = "force-dynamic"; + +export const GET = ( + req: NextRequest, + { params }: { params: { tagId: string } }, +) => + buildHandler({ + req, + searchParamsSchema: zPagination, + handler: async ({ api, searchParams }) => { + const bookmarks = await api.bookmarks.getBookmarks({ + tagId: params.tagId, + limit: searchParams.limit, + cursor: searchParams.cursor, + }); + return { + status: 200, + resp: adaptPagination(bookmarks), + }; + }, + }); diff --git a/apps/web/app/api/v1/tags/[tagId]/route.ts b/apps/web/app/api/v1/tags/[tagId]/route.ts new file mode 100644 index 0000000..29b2721 --- /dev/null +++ b/apps/web/app/api/v1/tags/[tagId]/route.ts @@ -0,0 +1,55 @@ +import { NextRequest } from "next/server"; +import { buildHandler } from "@/app/api/v1/utils/handler"; + +import { zUpdateTagRequestSchema } from "@hoarder/shared/types/tags"; + +export const dynamic = "force-dynamic"; + +export const GET = ( + req: NextRequest, + { params }: { params: { tagId: string } }, +) => + buildHandler({ + req, + handler: async ({ api }) => { + const tag = await api.tags.get({ + tagId: params.tagId, + }); + return { + status: 200, + resp: tag, + }; + }, + }); + +export const PATCH = ( + req: NextRequest, + { params }: { params: { tagId: string } }, +) => + buildHandler({ + req, + bodySchema: zUpdateTagRequestSchema.omit({ tagId: true }), + handler: async ({ api, body }) => { + const tag = await api.tags.update({ + tagId: params.tagId, + ...body!, + }); + return { status: 200, resp: tag }; + }, + }); + +export const DELETE = ( + req: NextRequest, + { params }: { params: { tagId: string } }, +) => + buildHandler({ + req, + handler: async ({ api }) => { + await api.tags.delete({ + tagId: params.tagId, + }); + return { + status: 204, + }; + }, + }); diff --git a/apps/web/app/api/v1/tags/route.ts b/apps/web/app/api/v1/tags/route.ts new file mode 100644 index 0000000..9625820 --- /dev/null +++ b/apps/web/app/api/v1/tags/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { buildHandler } from "../utils/handler"; + +export const dynamic = "force-dynamic"; + +export const GET = (req: NextRequest) => + buildHandler({ + req, + handler: async ({ api }) => { + const tags = await api.tags.list(); + return { status: 200, resp: tags }; + }, + }); diff --git a/apps/web/app/api/v1/utils/handler.ts b/apps/web/app/api/v1/utils/handler.ts new file mode 100644 index 0000000..180f4a6 --- /dev/null +++ b/apps/web/app/api/v1/utils/handler.ts @@ -0,0 +1,170 @@ +import { NextRequest } from "next/server"; +import { + createContextFromRequest, + createTrcpClientFromCtx, +} from "@/server/api/client"; +import { TRPCError } from "@trpc/server"; +import { z, ZodError } from "zod"; + +import { Context } from "@lifetracker/trpc"; + +function trpcCodeToHttpCode(code: TRPCError["code"]) { + switch (code) { + case "BAD_REQUEST": + case "PARSE_ERROR": + return 400; + case "UNAUTHORIZED": + return 401; + case "FORBIDDEN": + return 403; + case "NOT_FOUND": + return 404; + case "METHOD_NOT_SUPPORTED": + return 405; + case "TIMEOUT": + return 408; + case "PAYLOAD_TOO_LARGE": + return 413; + case "INTERNAL_SERVER_ERROR": + return 500; + default: + return 500; + } +} + +interface ErrorMessage { + path: (string | number)[]; + message: string; +} + +function formatZodError(error: ZodError): string { + if (!error.issues) { + return error.message || "An unknown error occurred"; + } + + const errors: ErrorMessage[] = error.issues.map((issue) => ({ + path: issue.path, + message: issue.message, + })); + + const formattedErrors = errors.map((err) => { + const path = err.path.join("."); + return path ? `${path}: ${err.message}` : err.message; + }); + + return `${formattedErrors.join(", ")}`; +} + +export interface TrpcAPIRequest { + ctx: Context; + api: ReturnType; + searchParams: SearchParamsT extends z.ZodTypeAny + ? z.infer + : undefined; + body: BodyType extends z.ZodTypeAny + ? z.infer | undefined + : undefined; +} + +type SchemaType = T extends z.ZodTypeAny + ? z.infer | undefined + : undefined; + +export async function buildHandler< + SearchParamsT extends z.ZodTypeAny | undefined, + BodyT extends z.ZodTypeAny | undefined, + InputT extends TrpcAPIRequest, +>({ + req, + handler, + searchParamsSchema, + bodySchema, +}: { + req: NextRequest; + handler: (req: InputT) => Promise<{ status: number; resp?: object }>; + searchParamsSchema?: SearchParamsT | undefined; + bodySchema?: BodyT | undefined; +}) { + try { + const ctx = await createContextFromRequest(req); + const api = createTrcpClientFromCtx(ctx); + + let searchParams: SchemaType | undefined = undefined; + if (searchParamsSchema !== undefined) { + searchParams = searchParamsSchema.parse( + Object.fromEntries(req.nextUrl.searchParams.entries()), + ) as SchemaType; + } + + let body: SchemaType | undefined = undefined; + if (bodySchema) { + if (req.headers.get("Content-Type") !== "application/json") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Content-Type must be application/json", + }); + } + + let bodyJson = undefined; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + bodyJson = await req.json(); + } catch (e) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid JSON: ${(e as Error).message}`, + }); + } + body = bodySchema.parse(bodyJson) as SchemaType; + } + + const { status, resp } = await handler({ + ctx, + api, + searchParams, + body, + } as InputT); + + return new Response(resp ? JSON.stringify(resp) : null, { + status, + headers: { + "Content-Type": "application/json", + }, + }); + } catch (e) { + if (e instanceof ZodError) { + return new Response( + JSON.stringify({ code: "ParseError", message: formatZodError(e) }), + { + status: 400, + headers: { + "Content-Type": "application/json", + }, + }, + ); + } + if (e instanceof TRPCError) { + let message = e.message; + if (e.cause instanceof ZodError) { + message = formatZodError(e.cause); + } + return new Response(JSON.stringify({ code: e.code, error: message }), { + status: trpcCodeToHttpCode(e.code), + headers: { + "Content-Type": "application/json", + }, + }); + } else { + const error = e as Error; + console.error( + `Unexpected error in: ${req.method} ${req.nextUrl.pathname}:\n${error.stack}`, + ); + return new Response(JSON.stringify({ code: "UnknownError" }), { + status: 500, + headers: { + "Content-Type": "application/json", + }, + }); + } + } +} diff --git a/apps/web/app/api/v1/utils/pagination.ts b/apps/web/app/api/v1/utils/pagination.ts new file mode 100644 index 0000000..5ce9ac8 --- /dev/null +++ b/apps/web/app/api/v1/utils/pagination.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +import { + MAX_NUM_BOOKMARKS_PER_PAGE, + zCursorV2, +} from "@hoarder/shared/types/bookmarks"; + +export const zPagination = z.object({ + limit: z.coerce.number().max(MAX_NUM_BOOKMARKS_PER_PAGE).optional(), + cursor: z + .string() + .refine((val) => val.includes("_"), "Must be a valid cursor") + .transform((val) => { + const [id, createdAt] = val.split("_"); + return { id, createdAt }; + }) + .pipe(z.object({ id: z.string(), createdAt: z.coerce.date() })) + .optional(), +}); + +export function adaptPagination< + T extends { nextCursor: z.infer | null }, +>(input: T) { + const { nextCursor, ...rest } = input; + if (!nextCursor) { + return input; + } + return { + ...rest, + nextCursor: `${nextCursor.id}_${nextCursor.createdAt.toISOString()}`, + }; +} diff --git a/apps/web/app/api/v1/utils/types.ts b/apps/web/app/api/v1/utils/types.ts new file mode 100644 index 0000000..c0e20df --- /dev/null +++ b/apps/web/app/api/v1/utils/types.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const zStringBool = z + .string() + .refine((val) => val === "true" || val === "false", "Must be true or false") + .transform((val) => val === "true"); diff --git a/apps/web/app/apple-icon.png b/apps/web/app/apple-icon.png new file mode 100644 index 0000000..aced9e4 Binary files /dev/null and b/apps/web/app/apple-icon.png differ diff --git a/apps/web/app/dashboard/@modal/(.)preview/[bookmarkId]/page.tsx b/apps/web/app/dashboard/@modal/(.)preview/[bookmarkId]/page.tsx new file mode 100644 index 0000000..432e7a6 --- /dev/null +++ b/apps/web/app/dashboard/@modal/(.)preview/[bookmarkId]/page.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import BookmarkPreview from "@/components/dashboard/preview/BookmarkPreview"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; + +export default function BookmarkPreviewPage({ + params, +}: { + params: { bookmarkId: string }; +}) { + const router = useRouter(); + + const [open, setOpen] = useState(true); + + const setOpenWithRouter = (value: boolean) => { + setOpen(value); + if (!value) { + router.back(); + } + }; + + return ( + + e.preventDefault()} + > + + + + ); +} diff --git a/apps/web/app/dashboard/@modal/[...catchAll]/page.tsx b/apps/web/app/dashboard/@modal/[...catchAll]/page.tsx new file mode 100644 index 0000000..1fd97c2 --- /dev/null +++ b/apps/web/app/dashboard/@modal/[...catchAll]/page.tsx @@ -0,0 +1,3 @@ +export default function CatchAll() { + return null; +} diff --git a/apps/web/app/dashboard/@modal/default.tsx b/apps/web/app/dashboard/@modal/default.tsx new file mode 100644 index 0000000..6ddf1b7 --- /dev/null +++ b/apps/web/app/dashboard/@modal/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return null; +} diff --git a/apps/web/app/dashboard/admin/page.tsx b/apps/web/app/dashboard/admin/page.tsx new file mode 100644 index 0000000..18efc88 --- /dev/null +++ b/apps/web/app/dashboard/admin/page.tsx @@ -0,0 +1,23 @@ +import { redirect } from "next/navigation"; +import AdminActions from "@/components/dashboard/admin/AdminActions"; +import ServerStats from "@/components/dashboard/admin/ServerStats"; +import UserList from "@/components/dashboard/admin/UserList"; +import { getServerAuthSession } from "@/server/auth"; + +export default async function AdminPage() { + const session = await getServerAuthSession(); + if (!session || session.user.role !== "admin") { + redirect("/"); + } + return ( + <> +
+ + +
+
+ +
+ + ); +} diff --git a/apps/web/app/dashboard/archive/page.tsx b/apps/web/app/dashboard/archive/page.tsx new file mode 100644 index 0000000..becb6a5 --- /dev/null +++ b/apps/web/app/dashboard/archive/page.tsx @@ -0,0 +1,24 @@ +import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks"; +import InfoTooltip from "@/components/ui/info-tooltip"; + +function header() { + return ( +
+

🗄️ Archive

+ +

Archived bookmarks won't appear in the homepage

+
+
+ ); +} + +export default async function ArchivedBookmarkPage() { + return ( + + ); +} diff --git a/apps/web/app/dashboard/bookmarks/page.tsx b/apps/web/app/dashboard/bookmarks/page.tsx new file mode 100644 index 0000000..a7b22fe --- /dev/null +++ b/apps/web/app/dashboard/bookmarks/page.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks"; + +export default async function BookmarksPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/dashboard/cleanups/page.tsx b/apps/web/app/dashboard/cleanups/page.tsx new file mode 100644 index 0000000..ca9187e --- /dev/null +++ b/apps/web/app/dashboard/cleanups/page.tsx @@ -0,0 +1,21 @@ +import { TagDuplicationDetection } from "@/components/dashboard/cleanups/TagDuplicationDetention"; +import { Separator } from "@/components/ui/separator"; +import { Paintbrush, Tags } from "lucide-react"; + +export default function Cleanups() { + return ( +
+ + + Cleanups + + + + + Duplicate Tags + + + +
+ ); +} diff --git a/apps/web/app/dashboard/error.tsx b/apps/web/app/dashboard/error.tsx new file mode 100644 index 0000000..556e59a --- /dev/null +++ b/apps/web/app/dashboard/error.tsx @@ -0,0 +1,9 @@ +"use client"; + +export default function Error() { + return ( +
+
Something went wrong
+
+ ); +} diff --git a/apps/web/app/dashboard/favourites/page.tsx b/apps/web/app/dashboard/favourites/page.tsx new file mode 100644 index 0000000..be20bd2 --- /dev/null +++ b/apps/web/app/dashboard/favourites/page.tsx @@ -0,0 +1,16 @@ +import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks"; + +export default async function FavouritesBookmarkPage() { + return ( + +

⭐️ Favourites

+ + } + query={{ favourited: true }} + showDivider={true} + showEditorCard={true} + /> + ); +} diff --git a/apps/web/app/dashboard/feeds/[feedId]/page.tsx b/apps/web/app/dashboard/feeds/[feedId]/page.tsx new file mode 100644 index 0000000..ed5f9e4 --- /dev/null +++ b/apps/web/app/dashboard/feeds/[feedId]/page.tsx @@ -0,0 +1,31 @@ +import { notFound } from "next/navigation"; +import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks"; +import { api } from "@/server/api/client"; +import { TRPCError } from "@trpc/server"; + +export default async function FeedPage({ + params, +}: { + params: { feedId: string }; +}) { + let feed; + try { + feed = await api.feeds.get({ feedId: params.feedId }); + } catch (e) { + if (e instanceof TRPCError) { + if (e.code == "NOT_FOUND") { + notFound(); + } + } + throw e; + } + + return ( + {feed.name}} + /> + ); +} diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx new file mode 100644 index 0000000..fb67b43 --- /dev/null +++ b/apps/web/app/dashboard/layout.tsx @@ -0,0 +1,37 @@ +import Header from "@/components/dashboard/header/Header"; +import MobileSidebar from "@/components/dashboard/sidebar/ModileSidebar"; +import Sidebar from "@/components/dashboard/sidebar/Sidebar"; +import DemoModeBanner from "@/components/DemoModeBanner"; +import { Separator } from "@/components/ui/separator"; +import ValidAccountCheck from "@/components/utils/ValidAccountCheck"; + +import serverConfig from "@lifetracker/shared/config"; + +export default async function Dashboard({ + children, + modal, +}: Readonly<{ + children: React.ReactNode; + modal: React.ReactNode; +}>) { + return ( +
+
+
+ +
+ +
+
+ {serverConfig.demoMode && } +
+ + +
+ {modal} +
{children}
+
+
+
+ ); +} diff --git a/apps/web/app/dashboard/lists/[listId]/page.tsx b/apps/web/app/dashboard/lists/[listId]/page.tsx new file mode 100644 index 0000000..f8c5e0b --- /dev/null +++ b/apps/web/app/dashboard/lists/[listId]/page.tsx @@ -0,0 +1,32 @@ +import { notFound } from "next/navigation"; +import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks"; +import ListHeader from "@/components/dashboard/lists/ListHeader"; +import { api } from "@/server/api/client"; +import { TRPCError } from "@trpc/server"; + +export default async function ListPage({ + params, +}: { + params: { listId: string }; +}) { + let list; + try { + list = await api.lists.get({ listId: params.listId }); + } catch (e) { + if (e instanceof TRPCError) { + if (e.code == "NOT_FOUND") { + notFound(); + } + } + throw e; + } + + return ( + } + /> + ); +} diff --git a/apps/web/app/dashboard/lists/page.tsx b/apps/web/app/dashboard/lists/page.tsx new file mode 100644 index 0000000..1c22ac3 --- /dev/null +++ b/apps/web/app/dashboard/lists/page.tsx @@ -0,0 +1,15 @@ +import AllListsView from "@/components/dashboard/lists/AllListsView"; +import { Separator } from "@/components/ui/separator"; +import { api } from "@/server/api/client"; + +export default async function ListsPage() { + const lists = await api.lists.list(); + + return ( +
+

📋 All Lists

+ + +
+ ); +} diff --git a/apps/web/app/dashboard/not-found.tsx b/apps/web/app/dashboard/not-found.tsx new file mode 100644 index 0000000..64df220 --- /dev/null +++ b/apps/web/app/dashboard/not-found.tsx @@ -0,0 +1,7 @@ +export default function NotFound() { + return ( +
+
Not Found :(
+
+ ); +} diff --git a/apps/web/app/dashboard/preview/[bookmarkId]/page.tsx b/apps/web/app/dashboard/preview/[bookmarkId]/page.tsx new file mode 100644 index 0000000..236f544 --- /dev/null +++ b/apps/web/app/dashboard/preview/[bookmarkId]/page.tsx @@ -0,0 +1,30 @@ +import { notFound } from "next/navigation"; +import BookmarkPreview from "@/components/dashboard/preview/BookmarkPreview"; +import { api } from "@/server/api/client"; +import { TRPCError } from "@trpc/server"; + +export default async function BookmarkPreviewPage({ + params, +}: { + params: { bookmarkId: string }; +}) { + let bookmark; + try { + bookmark = await api.bookmarks.getBookmark({ + bookmarkId: params.bookmarkId, + }); + } catch (e) { + if (e instanceof TRPCError) { + if (e.code === "NOT_FOUND") { + notFound(); + } + } + throw e; + } + + return ( +
+ +
+ ); +} diff --git a/apps/web/app/dashboard/search/page.tsx b/apps/web/app/dashboard/search/page.tsx new file mode 100644 index 0000000..a239550 --- /dev/null +++ b/apps/web/app/dashboard/search/page.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { Suspense } from "react"; +import BookmarksGrid from "@/components/dashboard/bookmarks/BookmarksGrid"; +import { FullPageSpinner } from "@/components/ui/full-page-spinner"; +import { useBookmarkSearch } from "@/lib/hooks/bookmark-search"; + +function SearchComp() { + const { data } = useBookmarkSearch(); + + return ( +
+ {data ? ( + + ) : ( + + )} +
+ ); +} + +export default function SearchPage() { + return ( + + + + ); +} diff --git a/apps/web/app/dashboard/tags/[tagId]/page.tsx b/apps/web/app/dashboard/tags/[tagId]/page.tsx new file mode 100644 index 0000000..f6e02a6 --- /dev/null +++ b/apps/web/app/dashboard/tags/[tagId]/page.tsx @@ -0,0 +1,47 @@ +import { notFound } from "next/navigation"; +import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks"; +import EditableTagName from "@/components/dashboard/tags/EditableTagName"; +import { TagOptions } from "@/components/dashboard/tags/TagOptions"; +import { Button } from "@/components/ui/button"; +import { api } from "@/server/api/client"; +import { TRPCError } from "@trpc/server"; +import { MoreHorizontal } from "lucide-react"; + +export default async function TagPage({ + params, +}: { + params: { tagId: string }; +}) { + let tag; + try { + tag = await api.tags.get({ tagId: params.tagId }); + } catch (e) { + if (e instanceof TRPCError) { + if (e.code == "NOT_FOUND") { + notFound(); + } + } + throw e; + } + + return ( + + + + + + + + } + query={{ tagId: tag.id }} + showEditorCard={true} + /> + ); +} diff --git a/apps/web/app/dashboard/tags/page.tsx b/apps/web/app/dashboard/tags/page.tsx new file mode 100644 index 0000000..6caea51 --- /dev/null +++ b/apps/web/app/dashboard/tags/page.tsx @@ -0,0 +1,15 @@ +import AllTagsView from "@/components/dashboard/tags/AllTagsView"; +import { Separator } from "@/components/ui/separator"; +import { api } from "@/server/api/client"; + +export default async function TagsPage() { + const allTags = (await api.tags.list()).tags; + + return ( +
+ All Tags + + +
+ ); +} diff --git a/apps/web/app/dashboard/today/page.tsx b/apps/web/app/dashboard/today/page.tsx new file mode 100644 index 0000000..b85dc83 --- /dev/null +++ b/apps/web/app/dashboard/today/page.tsx @@ -0,0 +1,9 @@ +import React from "react"; + +export default async function TodayPage() { + return ( +
+ Hello from a logged in page! +
+ ); +} diff --git a/apps/web/app/favicon.ico b/apps/web/app/favicon.ico index 718d6fe..d940a93 100644 Binary files a/apps/web/app/favicon.ico and b/apps/web/app/favicon.ico differ diff --git a/apps/web/app/fonts/GeistMonoVF.woff b/apps/web/app/fonts/GeistMonoVF.woff deleted file mode 100644 index f2ae185..0000000 Binary files a/apps/web/app/fonts/GeistMonoVF.woff and /dev/null differ diff --git a/apps/web/app/fonts/GeistVF.woff b/apps/web/app/fonts/GeistVF.woff deleted file mode 100644 index 1b62daa..0000000 Binary files a/apps/web/app/fonts/GeistVF.woff and /dev/null differ diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css deleted file mode 100644 index 9147fcd..0000000 --- a/apps/web/app/globals.css +++ /dev/null @@ -1,39 +0,0 @@ -:root { - --background: #ffffff; - --foreground: #171717; -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - -html, -body { - max-width: 100vw; - overflow-x: hidden; -} - -body { - color: var(--foreground); - background: var(--background); -} - -* { - box-sizing: border-box; - padding: 0; - margin: 0; -} - -a { - color: inherit; - text-decoration: none; -} - -@media (prefers-color-scheme: dark) { - html { - color-scheme: dark; - } -} diff --git a/apps/web/app/icon.png b/apps/web/app/icon.png new file mode 100644 index 0000000..3278871 Binary files /dev/null and b/apps/web/app/icon.png differ diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 8469537..794e7f2 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,30 +1,72 @@ import type { Metadata } from "next"; -import localFont from "next/font/local"; -import "./globals.css"; +import { Inter } from "next/font/google"; -const geistSans = localFont({ - src: "./fonts/GeistVF.woff", - variable: "--font-geist-sans", -}); -const geistMono = localFont({ - src: "./fonts/GeistMonoVF.woff", - variable: "--font-geist-mono", +import "@lifetracker/tailwind-config/globals.css"; + +import type { Viewport } from "next"; +import React from "react"; +import { cookies } from "next/headers"; +import { Toaster } from "@/components/ui/toaster"; +import Providers from "@/lib/providers"; +import { + defaultUserLocalSettings, + parseUserLocalSettings, + USER_LOCAL_SETTINGS_COOKIE_NAME, +} from "@/lib/userLocalSettings/types"; +import { getServerAuthSession } from "@/server/auth"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; + +import { clientConfig } from "@lifetracker/shared/config"; + +const inter = Inter({ + subsets: ["latin"], + fallback: ["sans-serif"], }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Lifetracker", + applicationName: "Lifetracker", + description: + "The all-in-one life tracking app.", + manifest: "/manifest.json", + appleWebApp: { + capable: true, + title: "LifeTracker", + }, + formatDetection: { + telephone: false, + }, }; -export default function RootLayout({ +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, +}; + +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const session = await getServerAuthSession(); return ( - - {children} + + + {children} + + + ); diff --git a/apps/web/app/page.module.css b/apps/web/app/page.module.css deleted file mode 100644 index 3630662..0000000 --- a/apps/web/app/page.module.css +++ /dev/null @@ -1,188 +0,0 @@ -.page { - --gray-rgb: 0, 0, 0; - --gray-alpha-200: rgba(var(--gray-rgb), 0.08); - --gray-alpha-100: rgba(var(--gray-rgb), 0.05); - - --button-primary-hover: #383838; - --button-secondary-hover: #f2f2f2; - - display: grid; - grid-template-rows: 20px 1fr 20px; - align-items: center; - justify-items: center; - min-height: 100svh; - padding: 80px; - gap: 64px; - font-synthesis: none; -} - -@media (prefers-color-scheme: dark) { - .page { - --gray-rgb: 255, 255, 255; - --gray-alpha-200: rgba(var(--gray-rgb), 0.145); - --gray-alpha-100: rgba(var(--gray-rgb), 0.06); - - --button-primary-hover: #ccc; - --button-secondary-hover: #1a1a1a; - } -} - -.main { - display: flex; - flex-direction: column; - gap: 32px; - grid-row-start: 2; -} - -.main ol { - font-family: var(--font-geist-mono); - padding-left: 0; - margin: 0; - font-size: 14px; - line-height: 24px; - letter-spacing: -0.01em; - list-style-position: inside; -} - -.main li:not(:last-of-type) { - margin-bottom: 8px; -} - -.main code { - font-family: inherit; - background: var(--gray-alpha-100); - padding: 2px 4px; - border-radius: 4px; - font-weight: 600; -} - -.ctas { - display: flex; - gap: 16px; -} - -.ctas a { - appearance: none; - border-radius: 128px; - height: 48px; - padding: 0 20px; - border: none; - font-family: var(--font-geist-sans); - border: 1px solid transparent; - transition: background 0.2s, color 0.2s, border-color 0.2s; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - line-height: 20px; - font-weight: 500; -} - -a.primary { - background: var(--foreground); - color: var(--background); - gap: 8px; -} - -a.secondary { - border-color: var(--gray-alpha-200); - min-width: 180px; -} - -button.secondary { - appearance: none; - border-radius: 128px; - height: 48px; - padding: 0 20px; - border: none; - font-family: var(--font-geist-sans); - border: 1px solid transparent; - transition: background 0.2s, color 0.2s, border-color 0.2s; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - line-height: 20px; - font-weight: 500; - background: transparent; - border-color: var(--gray-alpha-200); - min-width: 180px; -} - -.footer { - font-family: var(--font-geist-sans); - grid-row-start: 3; - display: flex; - gap: 24px; -} - -.footer a { - display: flex; - align-items: center; - gap: 8px; -} - -.footer img { - flex-shrink: 0; -} - -/* Enable hover only on non-touch devices */ -@media (hover: hover) and (pointer: fine) { - a.primary:hover { - background: var(--button-primary-hover); - border-color: transparent; - } - - a.secondary:hover { - background: var(--button-secondary-hover); - border-color: transparent; - } - - .footer a:hover { - text-decoration: underline; - text-underline-offset: 4px; - } -} - -@media (max-width: 600px) { - .page { - padding: 32px; - padding-bottom: 80px; - } - - .main { - align-items: center; - } - - .main ol { - text-align: center; - } - - .ctas { - flex-direction: column; - } - - .ctas a { - font-size: 14px; - height: 40px; - padding: 0 16px; - } - - a.secondary { - min-width: auto; - } - - .footer { - flex-wrap: wrap; - align-items: center; - justify-content: center; - } -} - -@media (prefers-color-scheme: dark) { - .logo { - filter: invert(); - } -} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index efd6c9b..6e0547d 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,99 +1,11 @@ -import Image from "next/image"; -import { Button } from "@lifetracker/ui/button"; -import styles from "./page.module.css"; +import { redirect } from "next/navigation"; +import { getServerAuthSession } from "@/server/auth"; -export default function Home() { - return ( -
-
- Next.js logo -
    -
  1. - Get started by editing app/page.tsx -
  2. -
  3. Save and see your changes instantly.
  4. -
- - - -
- -
- ); +export default async function Home() { + const session = await getServerAuthSession(); + if (session) { + redirect("/dashboard/settings"); + } else { + redirect("/signin"); + } } diff --git a/apps/web/app/server/api/client.ts b/apps/web/app/server/api/client.ts deleted file mode 100644 index 80d4274..0000000 --- a/apps/web/app/server/api/client.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { headers } from "next/headers"; -import { getServerAuthSession } from "@/server/auth"; -import requestIp from "request-ip"; - -import { db } from "@lifetracker/db"; -import { Context, createCallerFactory } from "@lifetracker/trpc"; -import { authenticateApiKey } from "@lifetracker/trpc/auth"; -import { appRouter } from "@lifetracker/trpc/routers/_app"; - -export async function createContextFromRequest(req: Request) { - // TODO: This is a hack until we offer a proper REST API instead of the trpc based one. - // Check if the request has an Authorization token, if it does, assume that API key authentication is requested. - const ip = requestIp.getClientIp({ - headers: Object.fromEntries(req.headers.entries()), - }); - const authorizationHeader = req.headers.get("Authorization"); - if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) { - const token = authorizationHeader.split(" ")[1]; - try { - const user = await authenticateApiKey(token); - return { - user, - db, - req: { - ip, - }, - }; - } catch (e) { - // Fallthrough to cookie-based auth - } - } - - return createContext(db, ip); -} - -export const createContext = async ( - database?: typeof db, - ip?: string | null, -): Promise => { - const session = await getServerAuthSession(); - if (ip === undefined) { - const hdrs = headers(); - ip = requestIp.getClientIp({ - headers: Object.fromEntries(hdrs.entries()), - }); - } - return { - user: session?.user ?? null, - db: database ?? db, - req: { - ip, - }, - }; -}; - -const createCaller = createCallerFactory(appRouter); - -export const api = createCaller(createContext); - -export const createTrcpClientFromCtx = createCaller; \ No newline at end of file diff --git a/apps/web/app/settings/ai/page.tsx b/apps/web/app/settings/ai/page.tsx new file mode 100644 index 0000000..2b3d7a8 --- /dev/null +++ b/apps/web/app/settings/ai/page.tsx @@ -0,0 +1,5 @@ +import AISettings from "@/components/settings/AISettings"; + +export default function AISettingsPage() { + return ; +} diff --git a/apps/web/app/settings/api-keys/page.tsx b/apps/web/app/settings/api-keys/page.tsx new file mode 100644 index 0000000..1c3718d --- /dev/null +++ b/apps/web/app/settings/api-keys/page.tsx @@ -0,0 +1,9 @@ +import ApiKeySettings from "@/components/settings/ApiKeySettings"; + +export default async function ApiKeysPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/settings/feeds/page.tsx b/apps/web/app/settings/feeds/page.tsx new file mode 100644 index 0000000..d2d1f18 --- /dev/null +++ b/apps/web/app/settings/feeds/page.tsx @@ -0,0 +1,5 @@ +import FeedSettings from "@/components/settings/FeedSettings"; + +export default function FeedSettingsPage() { + return ; +} diff --git a/apps/web/app/settings/import/page.tsx b/apps/web/app/settings/import/page.tsx new file mode 100644 index 0000000..e27aa9a --- /dev/null +++ b/apps/web/app/settings/import/page.tsx @@ -0,0 +1,9 @@ +import ImportExport from "@/components/settings/ImportExport"; + +export default function ImportSettingsPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/settings/info/page.tsx b/apps/web/app/settings/info/page.tsx new file mode 100644 index 0000000..8027b09 --- /dev/null +++ b/apps/web/app/settings/info/page.tsx @@ -0,0 +1,11 @@ +import { ChangePassword } from "@/components/settings/ChangePassword"; +import UserDetails from "@/components/settings/UserDetails"; + +export default async function InfoPage() { + return ( +
+ + +
+ ); +} diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx new file mode 100644 index 0000000..3b90255 --- /dev/null +++ b/apps/web/app/settings/layout.tsx @@ -0,0 +1,34 @@ +import Header from "@/components/dashboard/header/Header"; +import DemoModeBanner from "@/components/DemoModeBanner"; +import MobileSidebar from "@/components/settings/sidebar/ModileSidebar"; +import Sidebar from "@/components/settings/sidebar/Sidebar"; +import { Separator } from "@/components/ui/separator"; +import ValidAccountCheck from "@/components/utils/ValidAccountCheck"; + +import serverConfig from "@lifetracker/shared/config"; + +export default async function SettingsLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+
+
+ +
+ +
+
+ {serverConfig.demoMode && } +
+ + +
+
{children}
+
+
+
+ ); +} diff --git a/apps/web/app/settings/page.tsx b/apps/web/app/settings/page.tsx new file mode 100644 index 0000000..de935c8 --- /dev/null +++ b/apps/web/app/settings/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +export default function SettingsHomepage() { + redirect("/settings/info"); + return null; +} diff --git a/apps/web/app/signin/page.tsx b/apps/web/app/signin/page.tsx new file mode 100644 index 0000000..441187e --- /dev/null +++ b/apps/web/app/signin/page.tsx @@ -0,0 +1,22 @@ +import { redirect } from "next/dist/client/components/navigation"; +import HoarderLogo from "@/components/HoarderIcon"; +import SignInForm from "@/components/signin/SignInForm"; +import { getServerAuthSession } from "@/server/auth"; + +export default async function SignInPage() { + const session = await getServerAuthSession(); + if (session) { + redirect("/"); + } + + return ( +
+
+ +
+
+ +
+
+ ); +} diff --git a/apps/web/auth_failures.log b/apps/web/auth_failures.log new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..fa674c9 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/apps/web/components/DemoModeBanner.tsx b/apps/web/components/DemoModeBanner.tsx new file mode 100644 index 0000000..8ab4dd0 --- /dev/null +++ b/apps/web/components/DemoModeBanner.tsx @@ -0,0 +1,7 @@ +export default function DemoModeBanner() { + return ( +
+ Demo mode is on. All modifications are disabled. +
+ ); +} diff --git a/apps/web/components/HoarderIcon.tsx b/apps/web/components/HoarderIcon.tsx new file mode 100644 index 0000000..d643e24 --- /dev/null +++ b/apps/web/components/HoarderIcon.tsx @@ -0,0 +1,17 @@ + +export default function LifetrackerLogo({ + height, + gap, +}: { + height: number; + gap: string; +}) { + return ( + + Lifetracker + + ); +} diff --git a/apps/web/components/dashboard/ChangeLayout.tsx b/apps/web/components/dashboard/ChangeLayout.tsx new file mode 100644 index 0000000..6ec3824 --- /dev/null +++ b/apps/web/components/dashboard/ChangeLayout.tsx @@ -0,0 +1,61 @@ +"use client"; + +import React from "react"; +import { ButtonWithTooltip } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useBookmarkLayout } from "@/lib/userLocalSettings/bookmarksLayout"; +import { updateBookmarksLayout } from "@/lib/userLocalSettings/userLocalSettings"; +import { + Check, + LayoutDashboard, + LayoutGrid, + LayoutList, + List, +} from "lucide-react"; + +type LayoutType = "masonry" | "grid" | "list"; + +const iconMap = { + masonry: LayoutDashboard, + grid: LayoutGrid, + list: LayoutList, + compact: List, +}; + +export default function ChangeLayout() { + const layout = useBookmarkLayout(); + + return ( + + + + {React.createElement(iconMap[layout], { size: 18 })} + + + + {Object.keys(iconMap).map((key) => ( + await updateBookmarksLayout(key as LayoutType)} + > +
+ {React.createElement(iconMap[key as LayoutType], { size: 18 })} + {key} +
+ {layout == key && } +
+ ))} +
+
+ ); +} diff --git a/apps/web/components/dashboard/EditableText.tsx b/apps/web/components/dashboard/EditableText.tsx new file mode 100644 index 0000000..55ce10c --- /dev/null +++ b/apps/web/components/dashboard/EditableText.tsx @@ -0,0 +1,147 @@ +import { useEffect, useRef, useState } from "react"; +import { ActionButtonWithTooltip } from "@/components/ui/action-button"; +import { ButtonWithTooltip } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipPortal, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Check, Pencil, X } from "lucide-react"; + +interface Props { + viewClassName?: string; + untitledClassName?: string; + editClassName?: string; + onSave: (title: string | null) => void; + isSaving: boolean; + originalText: string | null; + setEditable: (editable: boolean) => void; +} + +function EditMode({ + onSave: onSaveCB, + editClassName: className, + isSaving, + originalText, + setEditable, +}: Props) { + const ref = useRef(null); + + useEffect(() => { + if (ref.current) { + ref.current.focus(); + ref.current.textContent = originalText; + } + }, [ref]); + + const onSave = () => { + let toSave: string | null = ref.current?.textContent ?? null; + if (originalText == toSave) { + // Nothing to do here + return; + } + if (toSave == "") { + toSave = null; + } + onSaveCB(toSave); + setEditable(false); + }; + + return ( +
+
{ + if (e.key === "Enter") { + e.preventDefault(); + onSave(); + } + }} + /> + onSave()} + > + + + { + setEditable(false); + }} + > + + +
+ ); +} + +function ViewMode({ + originalText, + setEditable, + viewClassName, + untitledClassName, +}: Props) { + return ( + +
+ + {originalText ? ( +

{originalText}

+ ) : ( +

Untitled

+ )} +
+ { + setEditable(true); + }} + > + + +
+ + {originalText && ( + + {originalText} + + )} + +
+ ); +} + +export function EditableText(props: { + viewClassName?: string; + untitledClassName?: string; + editClassName?: string; + originalText: string | null; + onSave: (title: string | null) => void; + isSaving: boolean; +}) { + const [editable, setEditable] = useState(false); + + return editable ? ( + + ) : ( + + ); +} diff --git a/apps/web/components/dashboard/GlobalActions.tsx b/apps/web/components/dashboard/GlobalActions.tsx new file mode 100644 index 0000000..2969b17 --- /dev/null +++ b/apps/web/components/dashboard/GlobalActions.tsx @@ -0,0 +1,11 @@ +"use client"; + +import ChangeLayout from "@/components/dashboard/ChangeLayout"; + +export default function GlobalActions() { + return ( +
+ +
+ ); +} diff --git a/apps/web/components/dashboard/UploadDropzone.tsx b/apps/web/components/dashboard/UploadDropzone.tsx new file mode 100644 index 0000000..335ac72 --- /dev/null +++ b/apps/web/components/dashboard/UploadDropzone.tsx @@ -0,0 +1,146 @@ +"use client"; + +import React, { useCallback, useState } from "react"; +import useUpload from "@/lib/hooks/upload-file"; +import { cn } from "@/lib/utils"; +import { TRPCClientError } from "@trpc/client"; +import DropZone from "react-dropzone"; + +import { useCreateBookmarkWithPostHook } from "@hoarder/shared-react/hooks/bookmarks"; +import { BookmarkTypes } from "@hoarder/shared/types/bookmarks"; + +import LoadingSpinner from "../ui/spinner"; +import { toast } from "../ui/use-toast"; +import BookmarkAlreadyExistsToast from "../utils/BookmarkAlreadyExistsToast"; + +export function useUploadAsset() { + const { mutateAsync: createBookmark } = useCreateBookmarkWithPostHook({ + onSuccess: (resp) => { + if (resp.alreadyExists) { + toast({ + description: , + variant: "default", + }); + } else { + toast({ description: "Bookmark uploaded" }); + } + }, + onError: () => { + toast({ description: "Something went wrong", variant: "destructive" }); + }, + }); + + const { mutateAsync: runUploadAsset } = useUpload({ + onSuccess: async (resp) => { + const assetType = + resp.contentType === "application/pdf" ? "pdf" : "image"; + await createBookmark({ ...resp, type: BookmarkTypes.ASSET, assetType }); + }, + onError: (err, req) => { + toast({ + description: `${req.name}: ${err.error}`, + variant: "destructive", + }); + }, + }); + + return useCallback( + (file: File) => { + return runUploadAsset(file); + }, + [runUploadAsset], + ); +} + +function useUploadAssets({ + onFileUpload, + onFileError, + onAllUploaded, +}: { + onFileUpload: () => void; + onFileError: (name: string, e: Error) => void; + onAllUploaded: () => void; +}) { + const runUpload = useUploadAsset(); + + return async (files: File[]) => { + if (files.length == 0) { + return; + } + for (const file of files) { + try { + await runUpload(file); + onFileUpload(); + } catch (e) { + if (e instanceof TRPCClientError || e instanceof Error) { + onFileError(file.name, e); + } + } + } + onAllUploaded(); + }; +} + +export default function UploadDropzone({ + children, +}: { + children: React.ReactNode; +}) { + const [numUploading, setNumUploading] = useState(0); + const [numUploaded, setNumUploaded] = useState(0); + const uploadAssets = useUploadAssets({ + onFileUpload: () => { + setNumUploaded((c) => c + 1); + }, + onFileError: () => { + setNumUploaded((c) => c + 1); + }, + onAllUploaded: () => { + setNumUploading(0); + setNumUploaded(0); + return; + }, + }); + + const [isDragging, setDragging] = useState(false); + const onDrop = (acceptedFiles: File[]) => { + uploadAssets(acceptedFiles); + setNumUploading(acceptedFiles.length); + setDragging(false); + }; + + return ( + setDragging(true)} + onDragLeave={() => setDragging(false)} + > + {({ getRootProps, getInputProps }) => ( +
+ +
0 ? undefined : "hidden", + )} + > + {numUploading > 0 ? ( +
+

+ Uploading {numUploaded} / {numUploading} +

+ +
+ ) : ( +

+ Drop Your Image / Bookmark file +

+ )} +
+ {children} +
+ )} +
+ ); +} diff --git a/apps/web/components/dashboard/admin/AddUserDialog.tsx b/apps/web/components/dashboard/admin/AddUserDialog.tsx new file mode 100644 index 0000000..a13c6b8 --- /dev/null +++ b/apps/web/components/dashboard/admin/AddUserDialog.tsx @@ -0,0 +1,213 @@ +import { useEffect, useState } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { TRPCClientError } from "@trpc/client"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { zAdminCreateUserSchema } from "@hoarder/shared/types/admin"; + +type AdminCreateUserSchema = z.infer; + +export default function AddUserDialog({ + children, +}: { + children?: React.ReactNode; +}) { + const apiUtils = api.useUtils(); + const [isOpen, onOpenChange] = useState(false); + const form = useForm({ + resolver: zodResolver(zAdminCreateUserSchema), + defaultValues: { + name: "", + email: "", + password: "", + confirmPassword: "", + role: "user", + }, + }); + const { mutate, isPending } = api.admin.createUser.useMutation({ + onSuccess: () => { + toast({ + description: "User created successfully", + }); + onOpenChange(false); + apiUtils.users.list.invalidate(); + apiUtils.admin.userStats.invalidate(); + }, + onError: (error) => { + if (error instanceof TRPCClientError) { + toast({ + variant: "destructive", + description: error.message, + }); + } else { + toast({ + variant: "destructive", + description: "Failed to create user", + }); + } + }, + }); + + useEffect(() => { + if (!isOpen) { + form.reset(); + } + }, [isOpen, form]); + + return ( + + {children} + + + Add User + +
+ mutate(val))}> +
+ ( + + Name + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ( + + Confirm Password + + + + + + )} + /> + ( + + Role + + + + + + )} + /> + + + + + + Create + + +
+
+ +
+
+ ); +} diff --git a/apps/web/components/dashboard/admin/AdminActions.tsx b/apps/web/components/dashboard/admin/AdminActions.tsx new file mode 100644 index 0000000..a97552f --- /dev/null +++ b/apps/web/components/dashboard/admin/AdminActions.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { ActionButton } from "@/components/ui/action-button"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; + +export default function AdminActions() { + const { mutate: recrawlLinks, isPending: isRecrawlPending } = + api.admin.recrawlLinks.useMutation({ + onSuccess: () => { + toast({ + description: "Recrawl enqueued", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }); + + const { mutate: reindexBookmarks, isPending: isReindexPending } = + api.admin.reindexAllBookmarks.useMutation({ + onSuccess: () => { + toast({ + description: "Reindex enqueued", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }); + + const { + mutate: reRunInferenceOnAllBookmarks, + isPending: isInferencePending, + } = api.admin.reRunInferenceOnAllBookmarks.useMutation({ + onSuccess: () => { + toast({ + description: "Inference jobs enqueued", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }); + + const { mutateAsync: tidyAssets, isPending: isTidyAssetsPending } = + api.admin.tidyAssets.useMutation({ + onSuccess: () => { + toast({ + description: "Tidy assets request has been enqueued!", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }); + + return ( +
+
Actions
+
+ + recrawlLinks({ crawlStatus: "failure", runInference: true }) + } + > + Recrawl Failed Links Only + + + recrawlLinks({ crawlStatus: "all", runInference: true }) + } + > + Recrawl All Links + + + recrawlLinks({ crawlStatus: "all", runInference: false }) + } + > + Recrawl All Links (Without Inference) + + + reRunInferenceOnAllBookmarks({ taggingStatus: "failure" }) + } + > + Regenerate AI Tags for Failed Bookmarks Only + + reRunInferenceOnAllBookmarks({ taggingStatus: "all" })} + > + Regenerate AI Tags for All Bookmarks + + reindexBookmarks()} + > + Reindex All Bookmarks + + tidyAssets()} + > + Compact Assets + +
+
+ ); +} diff --git a/apps/web/components/dashboard/admin/ChangeRoleDialog.tsx b/apps/web/components/dashboard/admin/ChangeRoleDialog.tsx new file mode 100644 index 0000000..26ad5dc --- /dev/null +++ b/apps/web/components/dashboard/admin/ChangeRoleDialog.tsx @@ -0,0 +1,154 @@ +import { useEffect, useState } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { TRPCClientError } from "@trpc/client"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { changeRoleSchema } from "@hoarder/shared/types/admin"; + +type ChangeRoleSchema = z.infer; + +interface ChangeRoleDialogProps { + userId: string; + currentRole: "user" | "admin"; + children?: React.ReactNode; +} + +export default function ChangeRoleDialog({ + userId, + currentRole, + children, +}: ChangeRoleDialogProps) { + const apiUtils = api.useUtils(); + const [isOpen, onOpenChange] = useState(false); + const form = useForm({ + resolver: zodResolver(changeRoleSchema), + defaultValues: { + userId, + role: currentRole, + }, + }); + const { mutate, isPending } = api.admin.changeRole.useMutation({ + onSuccess: () => { + toast({ + description: "Role changed successfully", + }); + apiUtils.users.list.invalidate(); + onOpenChange(false); + }, + onError: (error) => { + if (error instanceof TRPCClientError) { + toast({ + variant: "destructive", + description: error.message, + }); + } else { + toast({ + variant: "destructive", + description: "Failed to change role", + }); + } + }, + }); + + useEffect(() => { + if (isOpen) { + form.reset(); + } + }, [isOpen, form]); + + return ( + + {children} + + + + Change Role + +
+ mutate(val))}> +
+ ( + + Role + + + + + + )} + /> + ( + + + + + + )} + /> + + + + + + Change + + +
+
+ +
+
+ ); +} diff --git a/apps/web/components/dashboard/admin/ResetPasswordDialog.tsx b/apps/web/components/dashboard/admin/ResetPasswordDialog.tsx new file mode 100644 index 0000000..32183d1 --- /dev/null +++ b/apps/web/components/dashboard/admin/ResetPasswordDialog.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; // Adjust the import path as needed +import { zodResolver } from "@hookform/resolvers/zod"; +import { TRPCClientError } from "@trpc/client"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { resetPasswordSchema } from "@hoarder/shared/types/admin"; + +interface ResetPasswordDialogProps { + userId: string; + children?: React.ReactNode; +} + +type ResetPasswordSchema = z.infer; + +export default function ResetPasswordDialog({ + children, + userId, +}: ResetPasswordDialogProps) { + const [isOpen, onOpenChange] = useState(false); + const form = useForm({ + resolver: zodResolver(resetPasswordSchema), + defaultValues: { + userId, + newPassword: "", + newPasswordConfirm: "", + }, + }); + const { mutate, isPending } = api.admin.resetPassword.useMutation({ + onSuccess: () => { + toast({ + description: "Password reset successfully", + }); + onOpenChange(false); + }, + onError: (error) => { + if (error instanceof TRPCClientError) { + toast({ + variant: "destructive", + description: error.message, + }); + } else { + toast({ + variant: "destructive", + description: "Failed to reset password", + }); + } + }, + }); + + useEffect(() => { + if (isOpen) { + form.reset(); + } + }, [isOpen, form]); + + return ( + + {children} + + + Reset Password + +
+ mutate(val))}> +
+ ( + + New Password + + + + + + )} + /> + ( + + Confirm New Password + + + + + + )} + /> + + + + + + Reset + + +
+
+ +
+
+ ); +} diff --git a/apps/web/components/dashboard/admin/ServerStats.tsx b/apps/web/components/dashboard/admin/ServerStats.tsx new file mode 100644 index 0000000..f45d86c --- /dev/null +++ b/apps/web/components/dashboard/admin/ServerStats.tsx @@ -0,0 +1,136 @@ +"use client"; + +import LoadingSpinner from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useClientConfig } from "@/lib/clientConfig"; +import { api } from "@/lib/trpc"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; + +const REPO_LATEST_RELEASE_API = + "https://api.github.com/repos/hoarder-app/hoarder/releases/latest"; +const REPO_RELEASE_PAGE = "https://github.com/hoarder-app/hoarder/releases"; + +function useLatestRelease() { + const { data } = useQuery({ + queryKey: ["latest-release"], + queryFn: async () => { + const res = await fetch(REPO_LATEST_RELEASE_API); + if (!res.ok) { + return undefined; + } + const data = (await res.json()) as { name: string }; + return data.name; + }, + staleTime: 60 * 60 * 1000, + enabled: !useClientConfig().disableNewReleaseCheck, + }); + return data; +} + +function ReleaseInfo() { + const currentRelease = useClientConfig().serverVersion ?? "NA"; + const latestRelease = useLatestRelease(); + + let newRelease; + if (latestRelease && currentRelease != latestRelease) { + newRelease = ( + + ({latestRelease} ⬆️) + + ); + } + return ( +
+ {currentRelease} + {newRelease} +
+ ); +} + +export default function ServerStats() { + const { data: serverStats } = api.admin.stats.useQuery(undefined, { + refetchInterval: 1000, + placeholderData: keepPreviousData, + }); + + if (!serverStats) { + return ; + } + + return ( + <> +
Server Stats
+
+
+
Total Users
+
{serverStats.numUsers}
+
+
+
+ Total Bookmarks +
+
+ {serverStats.numBookmarks} +
+
+
+
+ Server Version +
+ +
+
+ +
+
Background Jobs
+ + + Job + Queued + Pending + Failed + + + + Crawling Jobs + {serverStats.crawlStats.queued} + {serverStats.crawlStats.pending} + {serverStats.crawlStats.failed} + + + Indexing Jobs + {serverStats.indexingStats.queued} + - + - + + + Inference Jobs + {serverStats.inferenceStats.queued} + {serverStats.inferenceStats.pending} + {serverStats.inferenceStats.failed} + + + Tidy Assets Jobs + {serverStats.tidyAssetsStats.queued} + - + - + + +
+
+ + ); +} diff --git a/apps/web/components/dashboard/admin/UserList.tsx b/apps/web/components/dashboard/admin/UserList.tsx new file mode 100644 index 0000000..2937df2 --- /dev/null +++ b/apps/web/components/dashboard/admin/UserList.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { ActionButtonWithTooltip } from "@/components/ui/action-button"; +import { ButtonWithTooltip } from "@/components/ui/button"; +import LoadingSpinner from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { Check, KeyRound, Pencil, Trash, UserPlus, X } from "lucide-react"; +import { useSession } from "next-auth/react"; + +import AddUserDialog from "./AddUserDialog"; +import ChangeRoleDialog from "./ChangeRoleDialog"; +import ResetPasswordDialog from "./ResetPasswordDialog"; + +function toHumanReadableSize(size: number) { + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + if (size === 0) return "0 Bytes"; + const i = Math.floor(Math.log(size) / Math.log(1024)); + return (size / Math.pow(1024, i)).toFixed(2) + " " + sizes[i]; +} + +export default function UsersSection() { + const { data: session } = useSession(); + const invalidateUserList = api.useUtils().users.list.invalidate; + const { data: users } = api.users.list.useQuery(); + const { data: userStats } = api.admin.userStats.useQuery(); + const { mutate: deleteUser, isPending: isDeletionPending } = + api.users.delete.useMutation({ + onSuccess: () => { + toast({ + description: "User deleted", + }); + invalidateUserList(); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: `Something went wrong: ${e.message}`, + }); + }, + }); + + if (!users || !userStats) { + return ; + } + + return ( + <> +
+ Users List + + + + + +
+ + + + Name + Email + Num Bookmarks + Asset Sizes + Role + Local User + Actions + + + {users.users.map((u) => ( + + {u.name} + {u.email} + + {userStats[u.id].numBookmarks} + + + {toHumanReadableSize(userStats[u.id].assetSizes)} + + {u.role} + + {u.localUser ? : } + + + deleteUser({ userId: u.id })} + loading={isDeletionPending} + disabled={session!.user.id == u.id} + > + + + + + + + + + + + + + + + ))} + +
+ + ); +} diff --git a/apps/web/components/dashboard/bookmarks/AssetCard.tsx b/apps/web/components/dashboard/bookmarks/AssetCard.tsx new file mode 100644 index 0000000..61b3bc8 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/AssetCard.tsx @@ -0,0 +1,75 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; + +import type { ZBookmarkTypeAsset } from "@hoarder/shared/types/bookmarks"; +import { getAssetUrl } from "@hoarder/shared-react/utils/assetUtils"; +import { getSourceUrl } from "@hoarder/shared-react/utils/bookmarkUtils"; + +import { BookmarkLayoutAdaptingCard } from "./BookmarkLayoutAdaptingCard"; +import FooterLinkURL from "./FooterLinkURL"; + +function AssetImage({ + bookmark, + className, +}: { + bookmark: ZBookmarkTypeAsset; + className?: string; +}) { + const bookmarkedAsset = bookmark.content; + switch (bookmarkedAsset.assetType) { + case "image": { + return ( + + asset + + ); + } + case "pdf": { + return ( +