diff --git a/package.json b/package.json index c6fb6d0..5c1e710 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@aws-sdk/s3-request-presigner": "^3.1061.0", "@headlessui/react": "^2.2.10", "@huggingface/tokenizers": "^0.1.3", + "@mozilla/readability": "^0.6.0", "@napi-rs/canvas": "^0.1.100", "@tanstack/react-query": "^5.101.0", "@types/archiver": "^7.0.0", @@ -55,6 +56,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 +77,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 +86,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..67d9390 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@huggingface/tokenizers': specifier: ^0.1.3 version: 0.1.3 + '@mozilla/readability': + specifier: ^0.6.0 + version: 0.6.0 '@napi-rs/canvas': specifier: ^0.1.100 version: 0.1.100 @@ -83,6 +86,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 +149,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 +171,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 +186,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 +1255,13 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@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 +1708,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 +1734,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 +2287,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 +2449,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 +2564,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 +2697,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 +3173,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 +3531,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 +3829,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 +4679,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 +4721,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 +5756,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mixmark-io/domino@2.2.0': {} + + '@mozilla/readability@0.6.0': {} + '@napi-rs/canvas-android-arm64@0.1.100': optional: true @@ -6018,6 +6109,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 +6137,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 +6637,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 +6796,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 +6902,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 +6955,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 +7706,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 +8041,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 +8530,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 +9575,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 +9626,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..e66dc43 --- /dev/null +++ b/src/app/api/documents/import-url/route.ts @@ -0,0 +1,60 @@ +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'; +import { errorResponse } from '@/lib/server/errors/next-response'; + +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; + if (!ctxOrRes.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // 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('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/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index 7349150..588d892 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -22,7 +22,9 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'; 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 { IconButton, SidebarNavItem } from '@/components/ui'; +import { UploadIcon } from '@/components/icons/Icons'; +import { UploadMenuDialog } from '@/components/documents/UploadMenuDialog'; import { DocumentDndProvider } from './dnd/DocumentDndProvider'; import { DocumentSelectionProvider, @@ -209,6 +211,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 +622,14 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { onDropOnFolder={handleDropOnFolder} width={sidebarWidth} onWidthChange={setSidebarWidth} - topSlot={} + topSlot={( + setIsUploadDialogOpen(true)} + icon={} + label="Upload documents" + /> + )} bottomSlot={(
{sidebarUploadState && ( @@ -775,6 +785,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..51eb60d 100644 --- a/src/components/doclist/DocumentPreview.tsx +++ b/src/components/doclist/DocumentPreview.tsx @@ -288,7 +288,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
-
+
{isTxtFile ? (
                   {textPreview}
diff --git a/src/components/documents/UploadMenuDialog.tsx b/src/components/documents/UploadMenuDialog.tsx
new file mode 100644
index 0000000..da492f1
--- /dev/null
+++ b/src/components/documents/UploadMenuDialog.tsx
@@ -0,0 +1,447 @@
+'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 {
+  ModalFrame,
+  ModalTitle,
+  SidebarNav,
+  SidebarNavItem,
+  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 [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');
+      // Simulated processing time for micro-interaction polish
+      await new Promise((resolve) => setTimeout(resolve, 800));
+
+      setImportStep('uploading');
+      
+      // Create virtual file from markdown content
+      const safeTitle = (scrapeResult.title || 'Imported Web Page')
+        .replace(/[/\\?%*:|"<>\s]/g, '_')
+        .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 "${scrapeResult.title}"!`);
+      setWebUrl('');
+      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 (
+    
+      
+ {/* Header */} +
+ Add Documents + +
+ + {/* Mobile Navigation (Horizontal row of items) */} + + {SIDEBAR_SECTIONS.map((section) => { + const Icon = section.icon; + return ( + handleTabChange(section.id)} + active={activeTab === section.id} + icon={} + label={section.label} + /> + ); + })} + + + {/* Main Body (Split layout on desktop) */} +
+ {/* Desktop Navigation (Left sidebar) */} + + + {/* Tab Panel Content (Right detail view) */} +
+ + {/* 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" + /> +
+
+ +
+