diff --git a/package.json b/package.json index c6fb6d0..a8a7950 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "@aws-sdk/s3-request-presigner": "^3.1061.0", "@headlessui/react": "^2.2.10", "@huggingface/tokenizers": "^0.1.3", + "@microsoft/antissrf": "^1.0.0", + "@mozilla/readability": "^0.6.0", "@napi-rs/canvas": "^0.1.100", "@tanstack/react-query": "^5.101.0", "@types/archiver": "^7.0.0", @@ -55,6 +57,7 @@ "ffmpeg-static": "^5.3.0", "howler": "^2.2.4", "jszip": "^3.10.1", + "linkedom": "^0.18.12", "lru-cache": "^11.5.1", "next": "^15.5.19", "onnxruntime-node": "^1.26.0", @@ -75,6 +78,7 @@ "react-reader": "^2.0.15", "remark-gfm": "^4.0.1", "replicate": "^1.4.0", + "turndown": "^7.2.4", "uuid": "^11.1.1" }, "devDependencies": { @@ -83,10 +87,12 @@ "@tailwindcss/typography": "^0.5.19", "@types/better-sqlite3": "^7.6.13", "@types/howler": "^2.2.13", + "@types/mozilla-readability": "^0.2.1", "@types/node": "^20.19.41", "@types/pg": "^8.20.0", "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", + "@types/turndown": "^5.0.6", "@types/uuid": "^10.0.0", "eslint": "^9.39.4", "eslint-config-next": "^15.5.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b801186..1749ccb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,12 @@ importers: '@huggingface/tokenizers': specifier: ^0.1.3 version: 0.1.3 + '@microsoft/antissrf': + specifier: ^1.0.0 + version: 1.0.0 + '@mozilla/readability': + specifier: ^0.6.0 + version: 0.6.0 '@napi-rs/canvas': specifier: ^0.1.100 version: 0.1.100 @@ -83,6 +89,9 @@ importers: jszip: specifier: ^3.10.1 version: 3.10.1 + linkedom: + specifier: ^0.18.12 + version: 0.18.12(canvas@3.2.3) lru-cache: specifier: ^11.5.1 version: 11.5.1 @@ -143,6 +152,9 @@ importers: replicate: specifier: ^1.4.0 version: 1.4.0 + turndown: + specifier: ^7.2.4 + version: 7.2.4 uuid: specifier: ^11.1.1 version: 11.1.1 @@ -162,6 +174,9 @@ importers: '@types/howler': specifier: ^2.2.13 version: 2.2.13 + '@types/mozilla-readability': + specifier: ^0.2.1 + version: 0.2.1 '@types/node': specifier: ^20.19.41 version: 20.19.41 @@ -174,6 +189,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.16) + '@types/turndown': + specifier: ^5.0.6 + version: 5.0.6 '@types/uuid': specifier: ^10.0.0 version: 10.0.0 @@ -1240,6 +1258,16 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@microsoft/antissrf@1.0.0': + resolution: {integrity: sha512-AmO1ykSha52Ef/7UXvHgu8ElsGdgcLkF5nTl7YZGyR1AkbmlQYtiVeWp+CsjkFaJzKAH5ofXLZveMmOHwX/7Jw==} + + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + + '@mozilla/readability@0.6.0': + resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} + engines: {node: '>=14.0.0'} + '@napi-rs/canvas-android-arm64@0.1.100': resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} engines: {node: '>= 10'} @@ -1686,6 +1714,9 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mozilla-readability@0.2.1': + resolution: {integrity: sha512-RjLpe3MIpj53L+q50DTNlhv6KjHjgajFQwAF4KuxVZTa2+6Cs2UNAxS5awxABfZEHRYjGKeRDB21Zrtnsi911w==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -1709,6 +1740,9 @@ packages: '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + '@types/turndown@5.0.6': + resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -2259,6 +2293,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} @@ -2418,11 +2455,21 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -2523,6 +2570,19 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -2643,6 +2703,14 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3111,9 +3179,15 @@ packages: howler@2.2.4: resolution: {integrity: sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==} + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-response-object@3.0.2: resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==} @@ -3463,6 +3537,15 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkedom@0.18.12: + resolution: {integrity: sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==} + engines: {node: '>=16'} + peerDependencies: + canvas: '>= 2' + peerDependenciesMeta: + canvas: + optional: true + localforage@1.10.0: resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==} @@ -3752,6 +3835,9 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -4599,6 +4685,10 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + turndown@7.2.4: + resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} + engines: {node: '>=18', npm: '>=9'} + tweetnacl@1.0.3: resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} @@ -4637,6 +4727,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -5669,6 +5762,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@microsoft/antissrf@1.0.0': {} + + '@mixmark-io/domino@2.2.0': {} + + '@mozilla/readability@0.6.0': {} + '@napi-rs/canvas-android-arm64@0.1.100': optional: true @@ -6018,6 +6117,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/mozilla-readability@0.2.1': {} + '@types/ms@2.1.0': {} '@types/node@10.17.60': {} @@ -6044,6 +6145,8 @@ snapshots: dependencies: '@types/node': 20.19.41 + '@types/turndown@5.0.6': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -6542,6 +6645,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + boolbase@1.0.0: {} + bowser@2.14.1: {} brace-expansion@1.1.15: @@ -6699,8 +6804,20 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-what@6.2.2: {} + cssesc@3.0.0: {} + cssom@0.5.0: {} + csstype@3.2.3: {} d@1.0.2: @@ -6793,6 +6910,24 @@ snapshots: dependencies: esutils: 2.0.3 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dotenv@17.4.2: {} drizzle-kit@0.31.10: @@ -6828,6 +6963,10 @@ snapshots: dependencies: once: 1.4.0 + entities@4.5.0: {} + + entities@7.0.1: {} + env-paths@2.2.1: {} epubjs@0.3.93: @@ -7575,8 +7714,17 @@ snapshots: howler@2.2.4: {} + html-escaper@3.0.3: {} + html-url-attributes@3.0.1: {} + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + http-response-object@3.0.2: dependencies: '@types/node': 10.17.60 @@ -7901,6 +8049,16 @@ snapshots: lines-and-columns@1.2.4: {} + linkedom@0.18.12(canvas@3.2.3): + dependencies: + css-select: 5.2.2 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 10.1.0 + uhyphen: 0.2.0 + optionalDependencies: + canvas: 3.2.3 + localforage@1.10.0: dependencies: lie: 3.1.1 @@ -8380,6 +8538,10 @@ snapshots: normalize-path@3.0.0: {} + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -9421,6 +9583,10 @@ snapshots: dependencies: safe-buffer: 5.2.1 + turndown@7.2.4: + dependencies: + '@mixmark-io/domino': 2.2.0 + tweetnacl@1.0.3: {} type-check@0.4.0: @@ -9468,6 +9634,8 @@ snapshots: typescript@5.9.3: {} + uhyphen@0.2.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 diff --git a/src/app/(app)/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx index ef0ba89..6d97618 100644 --- a/src/app/(app)/html/[id]/page.tsx +++ b/src/app/(app)/html/[id]/page.tsx @@ -47,7 +47,7 @@ export default function HTMLPage() { const [isLoading, setIsLoading] = useState(true); const [activeSidebar, setActiveSidebar] = useState(null); const [containerHeight, setContainerHeight] = useState('auto'); - const [padPct, setPadPct] = useState(100); // 0..100 (100 = full width) + const [padPct, setPadPct] = useState(50); // 0..100 (50 = 50% default width) const [maxPadPx, setMaxPadPx] = useState(0); const inFlightDocIdRef = useRef(null); const loadedDocIdRef = useRef(null); diff --git a/src/app/api/documents/import-url/route.ts b/src/app/api/documents/import-url/route.ts new file mode 100644 index 0000000..674b93a --- /dev/null +++ b/src/app/api/documents/import-url/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { fetchAndParseUrl } from '@/lib/server/documents/web-loader'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: NextRequest) { + try { + // 1. Authenticate user request + const ctxOrRes = await requireAuthContext(req); + if (ctxOrRes instanceof Response) return ctxOrRes; + + // 2. Validate URL body parameter + let body; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + + const { url } = body; + if (!url || typeof url !== 'string') { + return NextResponse.json({ error: 'Missing or invalid url parameter' }, { status: 400 }); + } + + // 3. Fetch webpage, scrape readable elements, convert to markdown + const result = await fetchAndParseUrl(url); + + // 4. Return title and parsed markdown content + return NextResponse.json(result); + } catch (error) { + serverLogger.error( + { + event: 'documents.import-url.failed', + error: errorToLog(error), + }, + 'Failed to import and parse document from web URL' + ); + + const errorMessage = error instanceof Error ? error.message : 'Failed to import URL'; + + // Check if it's a validation error or network error to set suitable status codes + const isValidationError = + errorMessage.includes('forbidden') || + errorMessage.includes('disallowed by policy') || + errorMessage.includes('Invalid URL') || + errorMessage.includes('Only HTTP') || + errorMessage.includes('exceeds the maximum') || + errorMessage.includes('Unsupported content type'); + + return NextResponse.json( + { error: errorMessage }, + { status: isValidationError ? 400 : 500 } + ); + } +} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index cdc660f..f4a0b89 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -42,7 +42,7 @@ import { flushUserPreferencesSync } from '@/lib/client/api/user-state'; import toast from 'react-hot-toast'; import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery'; import { - SidebarNav, + SidebarDialog, SidebarNavItem, SegmentedControl, Button, @@ -50,8 +50,6 @@ import { IconButton, Input, Textarea, - ModalFrame, - ModalTitle, Select, } from '@/components/ui'; import ReactMarkdown from 'react-markdown'; @@ -562,90 +560,55 @@ export function SettingsModal({ return ( <> - + Settings + + + } + headerRight={ + + } + showCloseButton={false} + sections={visibleSections} + activeSectionId={activeSection} + onSectionChange={setActiveSection} + className="h-[490px]" + contentClassName={ + activeSection === 'admin' + ? 'bg-[radial-gradient(circle_at_top_right,color-mix(in_srgb,var(--accent),transparent_92%),transparent_35%)]' + : '' + } + customContent={ + isChangelogOpen ? ( + setIsChangelogOpen(false)} + /> + ) : undefined + } > - {/* Header */} -
-
- Settings - -
-
- -
-
- - {isChangelogOpen ? ( - setIsChangelogOpen(false)} - /> - ) : ( - <> - {/* Mobile nav */} - - {visibleSections.map((section) => { - const Icon = section.icon; - return ( - setActiveSection(section.id)} - active={activeSection === section.id} - icon={} - label={section.label} - /> - ); - })} - - -
- {/* Desktop: vertical sidebar */} - - - {/* Content */} -
{/* API Section */} {activeSection === 'api' && (
@@ -1174,11 +1137,7 @@ export function SettingsModal({
)} -
- - - )} -
+ {body ? ( -
+
{body.body || '_No release notes provided._'} diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index 7349150..f5ee2f6 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -23,6 +23,7 @@ import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog'; import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton'; import { DocumentUploader, type UploadBatchState } from '@/components/documents/DocumentUploader'; import { IconButton } from '@/components/ui'; +import { UploadMenuDialog } from '@/components/documents/UploadMenuDialog'; import { DocumentDndProvider } from './dnd/DocumentDndProvider'; import { DocumentSelectionProvider, @@ -209,6 +210,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [query, setQuery] = useState(''); const [activeUploadBatches, setActiveUploadBatches] = useState>({}); + const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false); const [isInitialized, setIsInitialized] = useState(cachedState !== null); @@ -619,7 +621,13 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { onDropOnFolder={handleDropOnFolder} width={sidebarWidth} onWidthChange={setSidebarWidth} - topSlot={} + topSlot={( + setIsUploadDialogOpen(true)} + /> + )} bottomSlot={(
{sidebarUploadState && ( @@ -775,6 +783,12 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { confirmText="Remove Folders" isDangerous /> + + setIsUploadDialogOpen(false)} + onUploadBatchChange={handleUploadBatchChange} + /> ); } diff --git a/src/components/doclist/DocumentPreview.tsx b/src/components/doclist/DocumentPreview.tsx index 2d0fc08..aea8b53 100644 --- a/src/components/doclist/DocumentPreview.tsx +++ b/src/components/doclist/DocumentPreview.tsx @@ -286,9 +286,20 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { ) : textPreview ? ( <>
-
+
-
+
{isTxtFile ? (
                   {textPreview}
diff --git a/src/components/documents/DocumentUploader.tsx b/src/components/documents/DocumentUploader.tsx
index ffa1b27..21950d6 100644
--- a/src/components/documents/DocumentUploader.tsx
+++ b/src/components/documents/DocumentUploader.tsx
@@ -12,6 +12,7 @@ interface DocumentUploaderProps {
   variant?: 'default' | 'compact' | 'overlay';
   children?: ReactNode;
   onUploadBatchChange?: (state: UploadBatchState) => void;
+  onClick?: () => void;
 }
 
 export interface UploadBatchState {
@@ -28,6 +29,7 @@ export function DocumentUploader({
   variant = 'default',
   children,
   onUploadBatchChange,
+  onClick,
 }: DocumentUploaderProps) {
   const uploaderId = useId();
   const enableDocx = useFeatureFlag('enableDocxConversion');
@@ -88,8 +90,8 @@ export function DocumentUploader({
     },
     multiple: true,
     disabled: isUploading,
-    noClick: variant === 'overlay',
-    noKeyboard: variant === 'overlay'
+    noClick: variant === 'overlay' || !!onClick,
+    noKeyboard: variant === 'overlay' || !!onClick
   });
 
   const isDisabled = isUploading;
@@ -132,6 +134,12 @@ export function DocumentUploader({
   return (
     
{ + if (onClick) { + e.stopPropagation(); + onClick(); + } + }} className={dropzoneSurfaceClass({ variant: variant === 'compact' ? 'compact' : 'default', active: isDragActive, diff --git a/src/components/documents/UploadMenuDialog.tsx b/src/components/documents/UploadMenuDialog.tsx new file mode 100644 index 0000000..1768cc0 --- /dev/null +++ b/src/components/documents/UploadMenuDialog.tsx @@ -0,0 +1,417 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import toast from 'react-hot-toast'; +import { useDocuments } from '@/contexts/DocumentContext'; +import { importUrl } from '@/lib/client/api/documents'; +import { + SidebarDialog, + SegmentedControl, + Input, + Textarea, + Button, +} from '@/components/ui'; +import { + UploadIcon, + FileIcon, + RefreshIcon, + CheckIcon, + BrowserIcon, +} from '@/components/icons/Icons'; +import { DocumentUploader, type UploadBatchState } from './DocumentUploader'; + +interface UploadMenuDialogProps { + isOpen: boolean; + onClose: () => void; + onUploadBatchChange?: (state: UploadBatchState) => void; +} + +type TabValue = 'file' | 'create' | 'url'; + +type SidebarSection = { + id: TabValue; + label: string; + icon: React.ComponentType>; +}; + +const SIDEBAR_SECTIONS: SidebarSection[] = [ + { id: 'file', label: 'Upload Files', icon: UploadIcon }, + { id: 'create', label: 'Create Document', icon: FileIcon }, + { id: 'url', label: 'Import from Web', icon: BrowserIcon }, +]; + +export function UploadMenuDialog({ + isOpen, + onClose, + onUploadBatchChange, +}: UploadMenuDialogProps) { + const { uploadDocuments } = useDocuments(); + const [activeTab, setActiveTab] = useState('file'); + + // --- Create Text/Markdown State --- + const [docName, setDocName] = useState(''); + const [docExtension, setDocExtension] = useState<'.md' | '.txt'>('.md'); + const [docContent, setDocContent] = useState(''); + const [isCreatingDoc, setIsCreatingDoc] = useState(false); + + // --- Import URL State --- + const [webUrl, setWebUrl] = useState(''); + const [webTitle, setWebTitle] = useState(''); + const [importStep, setImportStep] = useState< + 'idle' | 'fetching' | 'converting' | 'uploading' | 'error' + >('idle'); + const [importError, setImportError] = useState(null); + + const handleTabChange = useCallback((tab: TabValue) => { + setActiveTab(tab); + setImportError(null); + setImportStep('idle'); + }, []); + + // --- Create Doc Action --- + const handleCreateDocument = async () => { + if (!docName.trim()) { + toast.error('Please enter a document name'); + return; + } + + setIsCreatingDoc(true); + try { + let filename = docName.trim(); + const ext = docExtension; + if (!filename.toLowerCase().endsWith(ext)) { + filename += ext; + } + + const mimeType = ext === '.md' ? 'text/markdown' : 'text/plain'; + const file = new File([docContent], filename, { type: mimeType }); + + await uploadDocuments([file]); + toast.success(`"${filename}" created successfully!`); + + // Reset inputs & close + setDocName(''); + setDocContent(''); + onClose(); + } catch (err) { + console.error('Failed to create document:', err); + toast.error(err instanceof Error ? err.message : 'Failed to create document'); + } finally { + setIsCreatingDoc(false); + } + }; + + // --- Import URL Action --- + const handleImportUrl = async () => { + let cleanUrl = webUrl.trim(); + if (!cleanUrl) { + toast.error('Please enter a URL'); + return; + } + + if (!/^https?:\/\//i.test(cleanUrl)) { + cleanUrl = 'https://' + cleanUrl; + } + + setImportError(null); + setImportStep('fetching'); + + try { + // Step 1: Connect and scrape content on the server + const scrapeResult = await importUrl(cleanUrl); + + setImportStep('converting'); + setImportStep('uploading'); + + // Create virtual file from markdown content + const displayTitle = webTitle.trim() || scrapeResult.title || 'Imported Web Page'; + const safeTitle = displayTitle + .replace(/[/\\?%*:|"<>\s]+/g, '_') // collapse runs of disallowed chars + .replace(/^_+|_+$/g, '') // trim leading/trailing underscores + .substring(0, 80); + const filename = `${safeTitle}.md`; + const file = new File([scrapeResult.content], filename, { + type: 'text/markdown', + }); + + await uploadDocuments([file]); + + // Success + toast.success(`Successfully imported "${displayTitle}"!`); + setWebUrl(''); + setWebTitle(''); + setImportStep('idle'); + onClose(); + } catch (err) { + console.error('Failed to import URL:', err); + const message = + err instanceof Error ? err.message : 'An error occurred during import'; + setImportError(message); + setImportStep('error'); + toast.error(message); + } + }; + + const handleClose = () => { + // Only allow closing if not actively processing + if (isCreatingDoc || importStep === 'fetching' || importStep === 'converting' || importStep === 'uploading') { + return; + } + onClose(); + }; + + return ( + + {/* TAB 1: File Uploader */} + {activeTab === 'file' && ( +
+

+ Select files from your computer or drag and drop them anywhere. Supported formats include PDF, EPUB, TXT, and MD. +

+ { + onUploadBatchChange?.(state); + // Automatically close modal when files successfully start uploading + if (state.isActive) { + onClose(); + } + }} + /> +
+ )} + + {/* TAB 2: Create Custom Document */} + {activeTab === 'create' && ( +
+
+
+
+ setDocName(e.target.value)} + placeholder="Document title (e.g. notes)" + className="w-full font-medium" + controlSize="md" + disabled={isCreatingDoc} + /> +
+
+ + ariaLabel="Document format selection" + value={docExtension} + options={[ + { value: '.md', label: 'Markdown (.md)' }, + { value: '.txt', label: 'Plain Text (.txt)' }, + ]} + onChange={setDocExtension} + className="grid-cols-2" + /> +
+
+ +
+