One-click backups for AT Protocol

feat: add new UI

Turtlepaw db234dbe f98ca8c8

Changed files
+1908 -137
docs
src
src-tauri
+107
bun.lock
··· 4 4 "": { 5 5 "name": "atproto-backup", 6 6 "dependencies": { 7 + "@atcute/car": "^3.1.1", 7 8 "@atproto/api": "^0.15.25", 9 + "@atproto/jwk-webcrypto": "^0.1.9", 8 10 "@atproto/oauth-client-browser": "^0.3.27", 11 + "@radix-ui/react-avatar": "^1.1.10", 12 + "@radix-ui/react-dropdown-menu": "^2.1.15", 13 + "@radix-ui/react-progress": "^1.1.7", 14 + "@radix-ui/react-scroll-area": "^1.2.9", 9 15 "@radix-ui/react-slot": "^1.2.3", 10 16 "@tailwindcss/vite": "^4.1.11", 11 17 "@tauri-apps/api": "^2", 18 + "@tauri-apps/plugin-autostart": "~2", 12 19 "@tauri-apps/plugin-deep-link": "~2", 20 + "@tauri-apps/plugin-fs": "~2", 13 21 "@tauri-apps/plugin-opener": "^2", 22 + "@tauri-apps/plugin-store": "~2", 14 23 "antd": "^5.26.4", 15 24 "class-variance-authority": "^0.7.1", 16 25 "clsx": "^2.1.1", 17 26 "lucide-react": "^0.525.0", 18 27 "next": "^15.3.5", 28 + "next-themes": "^0.4.6", 19 29 "nextra": "^4.2.17", 20 30 "nextra-theme-docs": "^4.2.17", 21 31 "react": "^19.1.0", 22 32 "react-dom": "^19.1.0", 23 33 "shadcn": "^2.9.0", 34 + "sonner": "^2.0.6", 24 35 "tailwind-merge": "^3.3.1", 25 36 "tailwindcss": "^4.1.11", 26 37 }, ··· 61 72 "@antfu/ni": ["@antfu/ni@23.3.1", "", { "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nu": "bin/nu.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs" } }, "sha512-C90iyzm/jLV7Lomv2UzwWUzRv9WZr1oRsFRKsX5HjQL4EXrbi9H/RtBkjCP+NF+ABZXUKpAa4F1dkoTaea4zHg=="], 62 73 63 74 "@antfu/utils": ["@antfu/utils@8.1.1", "", {}, "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ=="], 75 + 76 + "@atcute/car": ["@atcute/car@3.1.1", "", { "dependencies": { "@atcute/cbor": "^2.2.4", "@atcute/cid": "^2.2.3", "@atcute/uint8array": "^1.0.2", "@atcute/varint": "^1.0.2", "yocto-queue": "^1.2.1" } }, "sha512-yhez/LqIl0zHubG6z/G/gqWYHmg7wJ5L4jNkbXj5FvZ4eOvmzsw8+ojbdq6wfMU4p5NhP0pUJNLkTZHbYSPmLg=="], 77 + 78 + "@atcute/cbor": ["@atcute/cbor@2.2.5", "", { "dependencies": { "@atcute/cid": "^2.2.3", "@atcute/multibase": "^1.1.4", "@atcute/uint8array": "^1.0.3" } }, "sha512-sBT8+6qau0mC3kwgmjl+nzqGn02xsE9b+kSgXm4/BRd9w8fwdRQYwcC9ApDlfaojrljJfcEkimppl/IcPOF3CA=="], 79 + 80 + "@atcute/cid": ["@atcute/cid@2.2.3", "", { "dependencies": { "@atcute/multibase": "^1.1.4", "@atcute/uint8array": "^1.0.2" } }, "sha512-WEzNSL1EuCVtCQDFYEBIm4dEP6PcMEwi8IYUVIWvT77eO5EjY58F63z5T4qMABxSBM0+L4kqMxypdL1Fzf6LZw=="], 81 + 82 + "@atcute/multibase": ["@atcute/multibase@1.1.4", "", { "dependencies": { "@atcute/uint8array": "^1.0.2" } }, "sha512-NUf5AeeSOmuZHGU+4GAaMtISJoG+ZHtW/vUVA4lK/YDt/7LODAW0Fd0NNIIUPVUoW0xJS6zSEIWvwLLuxmEHhA=="], 83 + 84 + "@atcute/uint8array": ["@atcute/uint8array@1.0.3", "", {}, "sha512-M/K+ihiVW8Pl2PFLzaC4E3l4JaZ1IH05Q0AbPWUC4cVHnd/gZ/1kAF5ngdtGvJeDMirHZ2VAy7OmAsPwR/2nlA=="], 85 + 86 + "@atcute/varint": ["@atcute/varint@1.0.2", "", {}, "sha512-0O31hePzzr4O3NGWHUKKOyta6CGSL+AtN8iir8grGxu9jXyI7DBARlw6PbgKA6uTAvsXdpmRmF8MX+p0TsLnNg=="], 64 87 65 88 "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.2.0", "@atproto-labs/simple-store-memory": "0.1.3", "@atproto/did": "0.1.5", "zod": "^3.23.8" } }, "sha512-y9GOx2gUETynDKmANnBrU5DTf+u0AwKBJpGns1vDDOYMdLdRCFIeYy3UH+TI8YOkcEazjgF5Q3m+LjwriE1KqQ=="], 66 89 ··· 380 403 381 404 "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], 382 405 406 + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], 407 + 408 + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], 409 + 410 + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], 411 + 412 + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="], 413 + 414 + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], 415 + 383 416 "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], 384 417 418 + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], 419 + 420 + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], 421 + 422 + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], 423 + 424 + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ=="], 425 + 426 + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], 427 + 428 + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], 429 + 430 + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], 431 + 432 + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew=="], 433 + 434 + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="], 435 + 436 + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], 437 + 438 + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], 439 + 440 + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], 441 + 442 + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="], 443 + 444 + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="], 445 + 446 + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.9", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A=="], 447 + 385 448 "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], 449 + 450 + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], 451 + 452 + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], 453 + 454 + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], 455 + 456 + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], 457 + 458 + "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="], 459 + 460 + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], 461 + 462 + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], 463 + 464 + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], 465 + 466 + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], 386 467 387 468 "@rc-component/async-validator": ["@rc-component/async-validator@5.0.4", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg=="], 388 469 ··· 538 619 539 620 "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ItB8RCKk+nCmqOxOvbNtltz6x1A4QX6cSM21kj3NkpcnjT9rHSMcfyf8WVI2fkoMUJR80iqCblUX6ARxC3lj6w=="], 540 621 622 + "@tauri-apps/plugin-autostart": ["@tauri-apps/plugin-autostart@2.5.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-smSt0vydfVB950AeYRbO2S/c01SZrgMVg4FOrFLQLom0R0amsu/8zYaxgttriBdxcofjBZuHv4hmROBQIBVXmA=="], 623 + 541 624 "@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-scFldWG5FDqLgbHauS5FtsE563Bo00sqYhhWTYwNzIOZFdOJtNY6tBqzqGJ8I1aehPtEVA0LcTNaq8fmDQbDGg=="], 542 625 626 + "@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.4.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-Sp8AdDcbyXyk6LD6Pmdx44SH3LPeNAvxR2TFfq/8CwqzfO1yOyV+RzT8fov0NNN7d9nvW7O7MtMAptJ42YXA5g=="], 627 + 543 628 "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.4.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-43VyN8JJtvKWJY72WI/KNZszTpDpzHULFxQs0CJBIYUdCRowQ6Q1feWTDb979N7nldqSuDOaBupZ6wz2nvuWwQ=="], 629 + 630 + "@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-mre8er0nXPhyEWQzWCpUd+UnEoBQYcoA5JYlwpwOV9wcxKqlXTGfminpKsE37ic8NUb2BIZqf0QQ9/U3ib2+/A=="], 544 631 545 632 "@theguild/remark-mermaid": ["@theguild/remark-mermaid@0.2.0", "", { "dependencies": { "mermaid": "^11.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-o8n57TJy0OI4PCrNw8z6S+vpHtrwoQZzTA5Y3fL0U1NDRIoMg/78duWgEBFsCZcWM1G6zjE91yg1aKCsDwgE2Q=="], 546 633 ··· 686 773 687 774 "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], 688 775 776 + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], 777 + 689 778 "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], 690 779 691 780 "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], ··· 898 987 899 988 "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], 900 989 990 + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], 991 + 901 992 "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], 902 993 903 994 "diff": ["diff@5.2.0", "", {}, "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A=="], ··· 1013 1104 "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], 1014 1105 1015 1106 "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], 1107 + 1108 + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], 1016 1109 1017 1110 "get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="], 1018 1111 ··· 1556 1649 1557 1650 "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], 1558 1651 1652 + "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], 1653 + 1654 + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], 1655 + 1656 + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], 1657 + 1559 1658 "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], 1560 1659 1561 1660 "reading-time": ["reading-time@1.5.0", "", {}, "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg=="], ··· 1677 1776 "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], 1678 1777 1679 1778 "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], 1779 + 1780 + "sonner": ["sonner@2.0.6", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q=="], 1680 1781 1681 1782 "source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="], 1682 1783 ··· 1814 1915 1815 1916 "url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="], 1816 1917 1918 + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], 1919 + 1920 + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], 1921 + 1817 1922 "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="], 1818 1923 1819 1924 "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], ··· 1865 1970 "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], 1866 1971 1867 1972 "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], 1973 + 1974 + "yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="], 1868 1975 1869 1976 "yoctocolors-cjs": ["yoctocolors-cjs@2.1.2", "", {}, "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA=="], 1870 1977
+3 -3
docs/public/client_metadata.json
··· 1 1 { 2 2 "client_id": "https://atproto-backup.pages.dev/client_metadata.json", 3 - "client_name": "My App", 3 + "client_name": "ATProto Backup", 4 4 "client_uri": "https://atproto-backup.pages.dev", 5 5 "logo_uri": "https://atproto-backup.pages.dev/logo.png", 6 6 "tos_uri": "https://atproto-backup.pages.dev/tos", ··· 8 8 "redirect_uris": [ 9 9 "https://atproto-backup.pages.dev/auth/complete" 10 10 ], 11 - "scope": "atproto transition:generic", 12 11 "grant_types": [ 13 12 "authorization_code", 14 13 "refresh_token" 15 14 ], 15 + "scope": "atproto transition:generic", 16 16 "response_types": [ 17 17 "code" 18 18 ], 19 - "token_endpoint_auth_method": "none", 20 19 "application_type": "web", 20 + "token_endpoint_auth_method": "none", 21 21 "dpop_bound_access_tokens": true 22 22 }
+11
package.json
··· 10 10 "tauri": "tauri" 11 11 }, 12 12 "dependencies": { 13 + "@atcute/car": "^3.1.1", 13 14 "@atproto/api": "^0.15.25", 15 + "@atproto/jwk-webcrypto": "^0.1.9", 14 16 "@atproto/oauth-client-browser": "^0.3.27", 17 + "@radix-ui/react-avatar": "^1.1.10", 18 + "@radix-ui/react-dropdown-menu": "^2.1.15", 19 + "@radix-ui/react-progress": "^1.1.7", 20 + "@radix-ui/react-scroll-area": "^1.2.9", 15 21 "@radix-ui/react-slot": "^1.2.3", 16 22 "@tailwindcss/vite": "^4.1.11", 17 23 "@tauri-apps/api": "^2", 24 + "@tauri-apps/plugin-autostart": "~2", 18 25 "@tauri-apps/plugin-deep-link": "~2", 26 + "@tauri-apps/plugin-fs": "~2", 19 27 "@tauri-apps/plugin-opener": "^2", 28 + "@tauri-apps/plugin-store": "~2", 20 29 "antd": "^5.26.4", 21 30 "class-variance-authority": "^0.7.1", 22 31 "clsx": "^2.1.1", 23 32 "lucide-react": "^0.525.0", 24 33 "next": "^15.3.5", 34 + "next-themes": "^0.4.6", 25 35 "nextra": "^4.2.17", 26 36 "nextra-theme-docs": "^4.2.17", 27 37 "react": "^19.1.0", 28 38 "react-dom": "^19.1.0", 29 39 "shadcn": "^2.9.0", 40 + "sonner": "^2.0.6", 30 41 "tailwind-merge": "^3.3.1", 31 42 "tailwindcss": "^4.1.11" 32 43 },
+124 -6
src-tauri/Cargo.lock
··· 232 232 "serde_json", 233 233 "tauri", 234 234 "tauri-build", 235 + "tauri-plugin-autostart", 235 236 "tauri-plugin-deep-link", 237 + "tauri-plugin-fs", 236 238 "tauri-plugin-opener", 237 239 "tauri-plugin-single-instance", 240 + "tauri-plugin-store", 241 + ] 242 + 243 + [[package]] 244 + name = "auto-launch" 245 + version = "0.5.0" 246 + source = "registry+https://github.com/rust-lang/crates.io-index" 247 + checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" 248 + dependencies = [ 249 + "dirs 4.0.0", 250 + "thiserror 1.0.69", 251 + "winreg 0.10.1", 238 252 ] 239 253 240 254 [[package]] ··· 752 766 753 767 [[package]] 754 768 name = "dirs" 769 + version = "4.0.0" 770 + source = "registry+https://github.com/rust-lang/crates.io-index" 771 + checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" 772 + dependencies = [ 773 + "dirs-sys 0.3.7", 774 + ] 775 + 776 + [[package]] 777 + name = "dirs" 755 778 version = "6.0.0" 756 779 source = "registry+https://github.com/rust-lang/crates.io-index" 757 780 checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" 758 781 dependencies = [ 759 - "dirs-sys", 782 + "dirs-sys 0.5.0", 783 + ] 784 + 785 + [[package]] 786 + name = "dirs-sys" 787 + version = "0.3.7" 788 + source = "registry+https://github.com/rust-lang/crates.io-index" 789 + checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" 790 + dependencies = [ 791 + "libc", 792 + "redox_users 0.4.6", 793 + "winapi", 760 794 ] 761 795 762 796 [[package]] ··· 767 801 dependencies = [ 768 802 "libc", 769 803 "option-ext", 770 - "redox_users", 804 + "redox_users 0.5.0", 771 805 "windows-sys 0.60.2", 772 806 ] 773 807 ··· 877 911 "rustc_version", 878 912 "toml 0.9.2", 879 913 "vswhom", 880 - "winreg", 914 + "winreg 0.55.0", 881 915 ] 882 916 883 917 [[package]] ··· 2937 2971 2938 2972 [[package]] 2939 2973 name = "redox_users" 2974 + version = "0.4.6" 2975 + source = "registry+https://github.com/rust-lang/crates.io-index" 2976 + checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 2977 + dependencies = [ 2978 + "getrandom 0.2.16", 2979 + "libredox", 2980 + "thiserror 1.0.69", 2981 + ] 2982 + 2983 + [[package]] 2984 + name = "redox_users" 2940 2985 version = "0.5.0" 2941 2986 source = "registry+https://github.com/rust-lang/crates.io-index" 2942 2987 checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" ··· 3619 3664 dependencies = [ 3620 3665 "anyhow", 3621 3666 "bytes", 3622 - "dirs", 3667 + "dirs 6.0.0", 3623 3668 "dunce", 3624 3669 "embed_plist", 3625 3670 "getrandom 0.3.3", ··· 3669 3714 dependencies = [ 3670 3715 "anyhow", 3671 3716 "cargo_toml", 3672 - "dirs", 3717 + "dirs 6.0.0", 3673 3718 "glob", 3674 3719 "heck 0.5.0", 3675 3720 "json-patch", ··· 3742 3787 ] 3743 3788 3744 3789 [[package]] 3790 + name = "tauri-plugin-autostart" 3791 + version = "2.5.0" 3792 + source = "registry+https://github.com/rust-lang/crates.io-index" 3793 + checksum = "062cdcd483d5e3148c9a64dabf8c574e239e2aa1193cf208d95cf89a676f87a5" 3794 + dependencies = [ 3795 + "auto-launch", 3796 + "serde", 3797 + "serde_json", 3798 + "tauri", 3799 + "tauri-plugin", 3800 + "thiserror 2.0.12", 3801 + ] 3802 + 3803 + [[package]] 3745 3804 name = "tauri-plugin-deep-link" 3746 3805 version = "2.4.0" 3747 3806 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3762 3821 ] 3763 3822 3764 3823 [[package]] 3824 + name = "tauri-plugin-fs" 3825 + version = "2.4.0" 3826 + source = "registry+https://github.com/rust-lang/crates.io-index" 3827 + checksum = "c341290d31991dbca38b31d412c73dfbdb070bb11536784f19dd2211d13b778f" 3828 + dependencies = [ 3829 + "anyhow", 3830 + "dunce", 3831 + "glob", 3832 + "percent-encoding", 3833 + "schemars 0.8.22", 3834 + "serde", 3835 + "serde_json", 3836 + "serde_repr", 3837 + "tauri", 3838 + "tauri-plugin", 3839 + "tauri-utils", 3840 + "thiserror 2.0.12", 3841 + "toml 0.8.23", 3842 + "url", 3843 + ] 3844 + 3845 + [[package]] 3765 3846 name = "tauri-plugin-opener" 3766 3847 version = "2.4.0" 3767 3848 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3800 3881 ] 3801 3882 3802 3883 [[package]] 3884 + name = "tauri-plugin-store" 3885 + version = "2.3.0" 3886 + source = "registry+https://github.com/rust-lang/crates.io-index" 3887 + checksum = "5916c609664a56c82aeaefffca9851fd072d4d41f73d63f22ee3ee451508194f" 3888 + dependencies = [ 3889 + "dunce", 3890 + "serde", 3891 + "serde_json", 3892 + "tauri", 3893 + "tauri-plugin", 3894 + "thiserror 2.0.12", 3895 + "tokio", 3896 + "tracing", 3897 + ] 3898 + 3899 + [[package]] 3803 3900 name = "tauri-runtime" 3804 3901 version = "2.7.0" 3805 3902 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4025 4122 "pin-project-lite", 4026 4123 "slab", 4027 4124 "socket2", 4125 + "tokio-macros", 4028 4126 "windows-sys 0.52.0", 4127 + ] 4128 + 4129 + [[package]] 4130 + name = "tokio-macros" 4131 + version = "2.5.0" 4132 + source = "registry+https://github.com/rust-lang/crates.io-index" 4133 + checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" 4134 + dependencies = [ 4135 + "proc-macro2", 4136 + "quote", 4137 + "syn 2.0.104", 4029 4138 ] 4030 4139 4031 4140 [[package]] ··· 4226 4335 checksum = "2da75ec677957aa21f6e0b361df0daab972f13a5bee3606de0638fd4ee1c666a" 4227 4336 dependencies = [ 4228 4337 "crossbeam-channel", 4229 - "dirs", 4338 + "dirs 6.0.0", 4230 4339 "libappindicator", 4231 4340 "muda", 4232 4341 "objc2 0.6.1", ··· 5031 5140 checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" 5032 5141 dependencies = [ 5033 5142 "memchr", 5143 + ] 5144 + 5145 + [[package]] 5146 + name = "winreg" 5147 + version = "0.10.1" 5148 + source = "registry+https://github.com/rust-lang/crates.io-index" 5149 + checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" 5150 + dependencies = [ 5151 + "winapi", 5034 5152 ] 5035 5153 5036 5154 [[package]]
+5
src-tauri/Cargo.toml
··· 23 23 serde = { version = "1", features = ["derive"] } 24 24 serde_json = "1" 25 25 tauri-plugin-deep-link = "2" 26 + tauri-plugin-store = "2" 27 + tauri-plugin-fs = "2" 26 28 27 29 [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies] 28 30 tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] } 31 + 32 + [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] 33 + tauri-plugin-autostart = "2"
+34 -2
src-tauri/capabilities/default.json
··· 7 7 ], 8 8 "permissions": [ 9 9 "core:default", 10 - "opener:default", 11 10 "core:window:default", 12 11 "core:window:allow-start-dragging", 13 12 "core:event:default", ··· 15 14 "core:window:allow-close", 16 15 "core:window:allow-minimize", 17 16 "core:window:allow-toggle-maximize", 18 - "core:window:allow-internal-toggle-maximize" 17 + "core:window:allow-internal-toggle-maximize", 18 + "opener:default", 19 + "store:default", 20 + { 21 + "identifier": "opener:allow-open-path", 22 + "allow": [ 23 + { 24 + "path": "$DOCUMENT/ATBackup" 25 + } 26 + ] 27 + }, 28 + "opener:allow-open-url", 29 + "fs:default", 30 + { 31 + "identifier": "fs:allow-exists", 32 + "allow": [ 33 + { 34 + "path": "$DOCUMENT/ATBackup/*" 35 + }, 36 + { 37 + "path": "$DOCUMENT/ATBackup" 38 + } 39 + ] 40 + }, 41 + { 42 + "identifier": "fs:allow-mkdir", 43 + "allow": [ 44 + { 45 + "path": "$DOCUMENT/ATBackup" 46 + } 47 + ] 48 + }, 49 + "fs:scope-document-recursive", 50 + "fs:allow-document-write-recursive" 19 51 ] 20 52 }
+14
src-tauri/capabilities/desktop.json
··· 1 + { 2 + "identifier": "desktop-capability", 3 + "platforms": [ 4 + "macOS", 5 + "windows", 6 + "linux" 7 + ], 8 + "windows": [ 9 + "main" 10 + ], 11 + "permissions": [ 12 + "autostart:default" 13 + ] 14 + }
src-tauri/icons/128x128.png

This is a binary file and will not be displayed.

src-tauri/icons/128x128@2x.png

This is a binary file and will not be displayed.

src-tauri/icons/32x32.png

This is a binary file and will not be displayed.

src-tauri/icons/64x64.png

This is a binary file and will not be displayed.

src-tauri/icons/Square107x107Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/Square142x142Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/Square150x150Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/Square284x284Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/Square30x30Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/Square310x310Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/Square44x44Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/Square71x71Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/Square89x89Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/StoreLogo.png

This is a binary file and will not be displayed.

src-tauri/icons/android/mipmap-hdpi/ic_launcher.png

This is a binary file and will not be displayed.

src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png

This is a binary file and will not be displayed.

src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png

This is a binary file and will not be displayed.

src-tauri/icons/android/mipmap-mdpi/ic_launcher.png

This is a binary file and will not be displayed.

src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png

This is a binary file and will not be displayed.

src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png

This is a binary file and will not be displayed.

src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png

This is a binary file and will not be displayed.

src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png

This is a binary file and will not be displayed.

src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png

This is a binary file and will not be displayed.

src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png

This is a binary file and will not be displayed.

src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png

This is a binary file and will not be displayed.

src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png

This is a binary file and will not be displayed.

src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png

This is a binary file and will not be displayed.

src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png

This is a binary file and will not be displayed.

src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png

This is a binary file and will not be displayed.

src-tauri/icons/icon.icns

This is a binary file and will not be displayed.

src-tauri/icons/icon.ico

This is a binary file and will not be displayed.

src-tauri/icons/icon.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-20x20@1x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-20x20@2x-1.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-20x20@2x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-20x20@3x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-29x29@1x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-29x29@2x-1.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-29x29@2x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-29x29@3x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-40x40@1x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-40x40@2x-1.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-40x40@2x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-40x40@3x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-512@2x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-60x60@2x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-60x60@3x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-76x76@1x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-76x76@2x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png

This is a binary file and will not be displayed.

+3
src-tauri/src/lib.rs
··· 9 9 #[cfg_attr(mobile, tauri::mobile_entry_point)] 10 10 pub fn run() { 11 11 let mut builder = tauri::Builder::default() 12 + .plugin(tauri_plugin_autostart::init()) 13 + .plugin(tauri_plugin_fs::init()) 14 + .plugin(tauri_plugin_store::Builder::new().build()) 12 15 .plugin(tauri_plugin_deep_link::init()) 13 16 .plugin(tauri_plugin_opener::init()) 14 17 .invoke_handler(tauri::generate_handler![greet])
+5 -4
src-tauri/tauri.conf.json
··· 1 1 { 2 2 "$schema": "https://schema.tauri.app/config/2", 3 - "productName": "atproto-backup", 3 + "productName": "ATBackup", 4 4 "version": "0.1.0", 5 - "identifier": "io.github.turtlepaw.atproto.backup", 5 + "identifier": "ATBackup", 6 6 "build": { 7 7 "beforeDevCommand": "bun run dev", 8 8 "devUrl": "http://localhost:1420", ··· 12 12 "app": { 13 13 "windows": [ 14 14 { 15 - "title": "ATP Backup", 15 + "title": "ATBackup", 16 16 "width": 800, 17 17 "height": 600, 18 - "decorations": false 18 + "decorations": false, 19 + "label": "main" 19 20 } 20 21 ], 21 22 "security": {
+34 -2
src/App.css
··· 143 143 144 144 .titlebar { 145 145 height: 40px; 146 - background: rgba(0, 0, 0, 0.6); 147 - backdrop-filter: blur(150px); 146 + background: rgba(0, 0, 0, 0.5); 147 + backdrop-filter: blur(100px); 148 148 user-select: none; 149 149 display: flex; 150 150 justify-content: flex-end; ··· 184 184 line-height: 1; 185 185 vertical-align: middle; 186 186 } 187 + 188 + /* Main content area that starts below the titlebar */ 189 + .main-content { 190 + flex: 1; 191 + overflow-y: auto; 192 + } 193 + 194 + .custom-scrollbar { 195 + scrollbar-width: thin; 196 + scrollbar-color: #6366f1 #18181b; 197 + } 198 + 199 + .custom-scrollbar::-webkit-scrollbar { 200 + width: 10px; 201 + background: #18181b; 202 + border-radius: 8px; 203 + } 204 + 205 + .custom-scrollbar::-webkit-scrollbar-thumb { 206 + background: #6366f1; 207 + border-radius: 8px; 208 + min-height: 40px; 209 + border: 2px solid #18181b; 210 + } 211 + 212 + .custom-scrollbar::-webkit-scrollbar-thumb:hover { 213 + background: #818cf8; 214 + } 215 + 216 + .custom-scrollbar::-webkit-scrollbar-corner { 217 + background: #18181b; 218 + }
+48 -103
src/App.tsx
··· 1 1 import { useState, useEffect } from "react"; 2 - import reactLogo from "./assets/react.svg"; 3 2 import { invoke } from "@tauri-apps/api/core"; 4 3 import "./App.css"; 5 4 import { Button } from "./components/ui/button"; 6 5 import LoginPage from "./routes/Login"; 7 6 import { getCurrentWindow } from "@tauri-apps/api/window"; 8 - import { 9 - Agent, 10 - AtpAgent, 11 - type AtpSessionData, 12 - type AtpSessionEvent, 13 - } from "@atproto/api"; 14 - import { 15 - BrowserOAuthClient, 16 - OAuthSession, 17 - } from "@atproto/oauth-client-browser"; 18 - import { LoaderIcon } from "lucide-react"; 19 - import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 20 - 21 - function LoggedInScreen({ 22 - session, 23 - onLogout, 24 - agent, 25 - }: { 26 - session: OAuthSession; 27 - onLogout: () => void; 28 - agent: Agent; 29 - }) { 30 - const [userData, setUserData] = useState<ProfileViewDetailed | null>(null); 31 - 32 - useEffect(() => { 33 - (async () => { 34 - const sessionData = await agent.getProfile({ actor: agent.assertDid }); 35 - setUserData(sessionData.data); 36 - })(); 37 - }, [agent]); 38 - 39 - return ( 40 - <div className="p-4 mt-10"> 41 - <div className="flex justify-between items-center mb-4"> 42 - <h1 className="text-2xl font-bold">Welcome!</h1> 43 - <Button onClick={onLogout}>Logout</Button> 44 - </div> 45 - <div className="bg-card rounded-lg p-4"> 46 - <p className="mb-2 text-white"> 47 - Logged in as: <span className="font-mono">@{userData?.handle}</span> 48 - </p> 49 - <p className="text-sm text-muted-foreground"></p> 50 - </div> 51 - </div> 52 - ); 53 - } 7 + import { Agent } from "@atproto/api"; 8 + import { OAuthSession } from "@atproto/oauth-client-browser"; 9 + import { LoaderCircleIcon, LoaderIcon } from "lucide-react"; 10 + import { AuthProvider, useAuth } from "./Auth"; 11 + import { initializeLocalStorage } from "./localstorage_ployfill"; 12 + import { Home } from "./routes/Home"; 13 + import { ThemeProvider } from "./theme-provider"; 14 + import { Toaster } from "sonner"; 15 + import { ScrollArea } from "./components/ui/scroll-area"; 54 16 55 - function App() { 56 - const [session, setSession] = useState<OAuthSession | null>(null); 17 + function AppContent() { 18 + const { isLoading, isAuthenticated, profile, client, login, logout } = 19 + useAuth(); 57 20 const appWindow = getCurrentWindow(); 58 - const [client, setClient] = useState<BrowserOAuthClient | null>(null); 59 - const [agent, setAgent] = useState<Agent | null>(null); 21 + 22 + const [isLocalStorageReady, setIsLocalStorageReady] = useState(false); 60 23 61 - // Load session from localStorage on mount 62 24 useEffect(() => { 63 - (async () => { 64 - const client = await BrowserOAuthClient.load({ 65 - clientId: "https://atproto-backup.pages.dev/client_metadata.json", 66 - handleResolver: "https://bsky.social", 67 - }); 25 + const initStorage = async () => { 26 + try { 27 + await initializeLocalStorage(); 28 + setIsLocalStorageReady(true); 29 + } catch (error) { 30 + console.error("Failed to initialize localStorage:", error); 31 + setIsLocalStorageReady(true); // Continue anyway 32 + } 33 + }; 68 34 69 - //@ts-expect-error 70 - const result: undefined | { session: OAuthSession; state?: string } = 71 - await client.init(); 72 - 73 - if (result) { 74 - const { session, state } = result; 75 - if (state != null) { 76 - console.log( 77 - `${session.sub} was successfully authenticated (state: ${state})` 78 - ); 79 - } else { 80 - console.log(`${session.sub} was restored (last active session)`); 81 - } 82 - setSession(session); 83 - setAgent(new Agent(session)); 84 - } 85 - setClient(client); 86 - })(); 35 + initStorage(); 87 36 }, []); 88 37 89 - const handleLogin = (newSession: OAuthSession) => { 90 - setSession(newSession); 91 - setAgent(new Agent(newSession)); 92 - }; 93 - 94 - const handleLogout = () => { 95 - setSession(null); 96 - setAgent(null); 97 - }; 98 - 99 38 return ( 100 - <main className="dark bg-background min-h-screen flex flex-col"> 39 + <main className="bg-background dark min-h-screen flex flex-col"> 101 40 <div className="titlebar" data-tauri-drag-region> 102 41 <div className="controls"> 103 42 <Button ··· 155 94 </div> 156 95 </div> 157 96 158 - {client ? ( 159 - <> 160 - {session && agent ? ( 161 - <LoggedInScreen 162 - session={session} 163 - onLogout={handleLogout} 164 - agent={agent} 165 - /> 166 - ) : ( 167 - <LoginPage onLogin={handleLogin} client={client} /> 168 - )} 169 - </> 170 - ) : ( 171 - <div className="flex-1 flex items-center justify-center"> 172 - <LoaderIcon className="animate-spin" /> 173 - </div> 174 - )} 97 + <ScrollArea> 98 + {isLoading || !isLocalStorageReady ? ( 99 + <div className="fixed inset-0 flex items-center justify-center"> 100 + <LoaderCircleIcon className="animate-spin text-white/80" /> 101 + </div> 102 + ) : isAuthenticated ? ( 103 + <Home profile={profile!!} onLogout={logout} /> 104 + ) : ( 105 + <LoginPage onLogin={login} client={client} /> 106 + )} 107 + </ScrollArea> 108 + 109 + <Toaster /> 175 110 </main> 111 + ); 112 + } 113 + 114 + function App() { 115 + return ( 116 + <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> 117 + <AuthProvider> 118 + <AppContent /> 119 + </AuthProvider> 120 + </ThemeProvider> 176 121 ); 177 122 } 178 123
+142
src/Auth.tsx
··· 1 + import { Agent } from "@atproto/api"; 2 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 3 + import { OAuthClient, type OAuthSession } from "@atproto/oauth-client"; 4 + import { BrowserOAuthClient } from "@atproto/oauth-client-browser"; 5 + import { load, Store } from "@tauri-apps/plugin-store"; 6 + import { 7 + createContext, 8 + ReactNode, 9 + useContext, 10 + useEffect, 11 + useState, 12 + } from "react"; 13 + 14 + interface StoredSession { 15 + did: string; 16 + } 17 + 18 + interface AuthContextType { 19 + isLoading: boolean; 20 + isAuthenticated: boolean; 21 + profile: ProfileViewDetailed | null; 22 + client: OAuthClient | null; 23 + login: (session: OAuthSession) => Promise<void>; 24 + logout: () => Promise<void>; 25 + agent: Agent | null; 26 + } 27 + 28 + const AuthContext = createContext<AuthContextType | null>(null); 29 + 30 + export function AuthProvider({ children }: { children: ReactNode }) { 31 + const [isLoading, setIsLoading] = useState(true); 32 + const [profile, setProfile] = useState<ProfileViewDetailed | null>(null); 33 + const [client, setClient] = useState<OAuthClient | null>(null); 34 + const [agent, setAgent] = useState<Agent | null>(null); 35 + 36 + // Initialize OAuth client 37 + useEffect(() => { 38 + const init = async () => { 39 + setIsLoading(true); 40 + try { 41 + const client = await BrowserOAuthClient.load({ 42 + clientId: "https://atproto-backup.pages.dev/client_metadata.json", 43 + handleResolver: "https://bsky.social", 44 + }); 45 + 46 + setClient(client); 47 + 48 + // Try to restore existing session from storage 49 + try { 50 + //@ts-expect-error 51 + const result: undefined | { session: OAuthSession; state?: string } = 52 + await client.init(); 53 + 54 + if (result) { 55 + const { session, state } = result; 56 + if (state != null) { 57 + console.log( 58 + `${session.sub} was successfully authenticated (state: ${state})` 59 + ); 60 + } else { 61 + console.log(`${session.sub} was restored (last active session)`); 62 + } 63 + await login(session); 64 + } else { 65 + const store = await Store.load("store.json", { autoSave: true }); 66 + const sub = (await store.get("session")) as string; 67 + if (sub) { 68 + const session = await client.restore(sub); 69 + await login(session); 70 + return; 71 + } 72 + } 73 + } catch (error) { 74 + console.error("Failed to restore session:", error); 75 + } 76 + } catch (error) { 77 + console.error("Failed to initialize auth:", error); 78 + } finally { 79 + setIsLoading(false); 80 + } 81 + }; 82 + 83 + init(); 84 + }, []); 85 + 86 + const login = async (session: OAuthSession) => { 87 + try { 88 + const store = await Store.load("store.json", { autoSave: true }); 89 + store.set("session", session.did); 90 + const agent = new Agent(session); 91 + setAgent(agent); 92 + setIsLoading(true); 93 + 94 + const actor = await agent.getProfile({ actor: session.did }); 95 + setProfile(actor.data); 96 + } catch (error) { 97 + console.error("Login failed:", error); 98 + throw error; 99 + } finally { 100 + setIsLoading(false); 101 + } 102 + }; 103 + 104 + const logout = async () => { 105 + try { 106 + if (client && profile) { 107 + // Revoke the session and clear storage 108 + await client.revoke(profile.did); 109 + const store = await Store.load("store.json", { autoSave: true }); 110 + // probably unnecessary 111 + await store.clear(); 112 + setProfile(null); 113 + } 114 + } catch (error) { 115 + console.error("Logout failed:", error); 116 + throw error; 117 + } 118 + }; 119 + 120 + return ( 121 + <AuthContext.Provider 122 + value={{ 123 + isLoading, 124 + isAuthenticated: !!profile, 125 + profile, 126 + client, 127 + login, 128 + logout, 129 + agent, 130 + }} 131 + > 132 + {children} 133 + </AuthContext.Provider> 134 + ); 135 + } 136 + export function useAuth() { 137 + const context = useContext(AuthContext); 138 + if (!context) { 139 + throw new Error("useAuth must be used within an AuthProvider"); 140 + } 141 + return context; 142 + }
+4
src/assets/ATTRIBUTIONS.md
··· 1 + `cloudy_sunset.png` - "Birds retreating to the forest at dusk on a cloudy evening" 2 + by Arachnidly, CC BY-SA 4.0, from Wikimedia Commons 3 + `night_sky.jpg` - by Mauro H Mathias, CC BY 2.0, from Flickr (https://commons.wikimedia.org/wiki/File:-i---i-_(24729862071).jpg) 4 + `milky_way.jpg` - "Milky Way over Bontecou Lake" by Juliancolton, CC BY-SA 4.0 (https://commons.wikimedia.org/wiki/File:Bontecou_Lake_Milky_Way_panorama.jpg)
src/assets/cloudy_sunset.png

This is a binary file and will not be displayed.

src/assets/milky_way.jpg

This is a binary file and will not be displayed.

src/assets/night_sky.jpg

This is a binary file and will not be displayed.

+51
src/components/ui/avatar.tsx
··· 1 + import * as React from "react" 2 + import * as AvatarPrimitive from "@radix-ui/react-avatar" 3 + 4 + import { cn } from "@/lib/utils" 5 + 6 + function Avatar({ 7 + className, 8 + ...props 9 + }: React.ComponentProps<typeof AvatarPrimitive.Root>) { 10 + return ( 11 + <AvatarPrimitive.Root 12 + data-slot="avatar" 13 + className={cn( 14 + "relative flex size-8 shrink-0 overflow-hidden rounded-full", 15 + className 16 + )} 17 + {...props} 18 + /> 19 + ) 20 + } 21 + 22 + function AvatarImage({ 23 + className, 24 + ...props 25 + }: React.ComponentProps<typeof AvatarPrimitive.Image>) { 26 + return ( 27 + <AvatarPrimitive.Image 28 + data-slot="avatar-image" 29 + className={cn("aspect-square size-full", className)} 30 + {...props} 31 + /> 32 + ) 33 + } 34 + 35 + function AvatarFallback({ 36 + className, 37 + ...props 38 + }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) { 39 + return ( 40 + <AvatarPrimitive.Fallback 41 + data-slot="avatar-fallback" 42 + className={cn( 43 + "bg-muted flex size-full items-center justify-center rounded-full", 44 + className 45 + )} 46 + {...props} 47 + /> 48 + ) 49 + } 50 + 51 + export { Avatar, AvatarImage, AvatarFallback }
+46
src/components/ui/badge.tsx
··· 1 + import * as React from "react" 2 + import { Slot } from "@radix-ui/react-slot" 3 + import { cva, type VariantProps } from "class-variance-authority" 4 + 5 + import { cn } from "@/lib/utils" 6 + 7 + const badgeVariants = cva( 8 + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 + { 10 + variants: { 11 + variant: { 12 + default: 13 + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 + secondary: 15 + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 + destructive: 17 + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 18 + outline: 19 + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 + }, 21 + }, 22 + defaultVariants: { 23 + variant: "default", 24 + }, 25 + } 26 + ) 27 + 28 + function Badge({ 29 + className, 30 + variant, 31 + asChild = false, 32 + ...props 33 + }: React.ComponentProps<"span"> & 34 + VariantProps<typeof badgeVariants> & { asChild?: boolean }) { 35 + const Comp = asChild ? Slot : "span" 36 + 37 + return ( 38 + <Comp 39 + data-slot="badge" 40 + className={cn(badgeVariants({ variant }), className)} 41 + {...props} 42 + /> 43 + ) 44 + } 45 + 46 + export { Badge, badgeVariants }
+255
src/components/ui/dropdown-menu.tsx
··· 1 + import * as React from "react" 2 + import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 3 + import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" 4 + 5 + import { cn } from "@/lib/utils" 6 + 7 + function DropdownMenu({ 8 + ...props 9 + }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { 10 + return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> 11 + } 12 + 13 + function DropdownMenuPortal({ 14 + ...props 15 + }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { 16 + return ( 17 + <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> 18 + ) 19 + } 20 + 21 + function DropdownMenuTrigger({ 22 + ...props 23 + }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { 24 + return ( 25 + <DropdownMenuPrimitive.Trigger 26 + data-slot="dropdown-menu-trigger" 27 + {...props} 28 + /> 29 + ) 30 + } 31 + 32 + function DropdownMenuContent({ 33 + className, 34 + sideOffset = 4, 35 + ...props 36 + }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { 37 + return ( 38 + <DropdownMenuPrimitive.Portal> 39 + <DropdownMenuPrimitive.Content 40 + data-slot="dropdown-menu-content" 41 + sideOffset={sideOffset} 42 + className={cn( 43 + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", 44 + className 45 + )} 46 + {...props} 47 + /> 48 + </DropdownMenuPrimitive.Portal> 49 + ) 50 + } 51 + 52 + function DropdownMenuGroup({ 53 + ...props 54 + }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { 55 + return ( 56 + <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> 57 + ) 58 + } 59 + 60 + function DropdownMenuItem({ 61 + className, 62 + inset, 63 + variant = "default", 64 + ...props 65 + }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { 66 + inset?: boolean 67 + variant?: "default" | "destructive" 68 + }) { 69 + return ( 70 + <DropdownMenuPrimitive.Item 71 + data-slot="dropdown-menu-item" 72 + data-inset={inset} 73 + data-variant={variant} 74 + className={cn( 75 + "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", 76 + className 77 + )} 78 + {...props} 79 + /> 80 + ) 81 + } 82 + 83 + function DropdownMenuCheckboxItem({ 84 + className, 85 + children, 86 + checked, 87 + ...props 88 + }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) { 89 + return ( 90 + <DropdownMenuPrimitive.CheckboxItem 91 + data-slot="dropdown-menu-checkbox-item" 92 + className={cn( 93 + "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", 94 + className 95 + )} 96 + checked={checked} 97 + {...props} 98 + > 99 + <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> 100 + <DropdownMenuPrimitive.ItemIndicator> 101 + <CheckIcon className="size-4" /> 102 + </DropdownMenuPrimitive.ItemIndicator> 103 + </span> 104 + {children} 105 + </DropdownMenuPrimitive.CheckboxItem> 106 + ) 107 + } 108 + 109 + function DropdownMenuRadioGroup({ 110 + ...props 111 + }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { 112 + return ( 113 + <DropdownMenuPrimitive.RadioGroup 114 + data-slot="dropdown-menu-radio-group" 115 + {...props} 116 + /> 117 + ) 118 + } 119 + 120 + function DropdownMenuRadioItem({ 121 + className, 122 + children, 123 + ...props 124 + }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) { 125 + return ( 126 + <DropdownMenuPrimitive.RadioItem 127 + data-slot="dropdown-menu-radio-item" 128 + className={cn( 129 + "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", 130 + className 131 + )} 132 + {...props} 133 + > 134 + <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> 135 + <DropdownMenuPrimitive.ItemIndicator> 136 + <CircleIcon className="size-2 fill-current" /> 137 + </DropdownMenuPrimitive.ItemIndicator> 138 + </span> 139 + {children} 140 + </DropdownMenuPrimitive.RadioItem> 141 + ) 142 + } 143 + 144 + function DropdownMenuLabel({ 145 + className, 146 + inset, 147 + ...props 148 + }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { 149 + inset?: boolean 150 + }) { 151 + return ( 152 + <DropdownMenuPrimitive.Label 153 + data-slot="dropdown-menu-label" 154 + data-inset={inset} 155 + className={cn( 156 + "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", 157 + className 158 + )} 159 + {...props} 160 + /> 161 + ) 162 + } 163 + 164 + function DropdownMenuSeparator({ 165 + className, 166 + ...props 167 + }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) { 168 + return ( 169 + <DropdownMenuPrimitive.Separator 170 + data-slot="dropdown-menu-separator" 171 + className={cn("bg-border -mx-1 my-1 h-px", className)} 172 + {...props} 173 + /> 174 + ) 175 + } 176 + 177 + function DropdownMenuShortcut({ 178 + className, 179 + ...props 180 + }: React.ComponentProps<"span">) { 181 + return ( 182 + <span 183 + data-slot="dropdown-menu-shortcut" 184 + className={cn( 185 + "text-muted-foreground ml-auto text-xs tracking-widest", 186 + className 187 + )} 188 + {...props} 189 + /> 190 + ) 191 + } 192 + 193 + function DropdownMenuSub({ 194 + ...props 195 + }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { 196 + return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} /> 197 + } 198 + 199 + function DropdownMenuSubTrigger({ 200 + className, 201 + inset, 202 + children, 203 + ...props 204 + }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { 205 + inset?: boolean 206 + }) { 207 + return ( 208 + <DropdownMenuPrimitive.SubTrigger 209 + data-slot="dropdown-menu-sub-trigger" 210 + data-inset={inset} 211 + className={cn( 212 + "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8", 213 + className 214 + )} 215 + {...props} 216 + > 217 + {children} 218 + <ChevronRightIcon className="ml-auto size-4" /> 219 + </DropdownMenuPrimitive.SubTrigger> 220 + ) 221 + } 222 + 223 + function DropdownMenuSubContent({ 224 + className, 225 + ...props 226 + }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) { 227 + return ( 228 + <DropdownMenuPrimitive.SubContent 229 + data-slot="dropdown-menu-sub-content" 230 + className={cn( 231 + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", 232 + className 233 + )} 234 + {...props} 235 + /> 236 + ) 237 + } 238 + 239 + export { 240 + DropdownMenu, 241 + DropdownMenuPortal, 242 + DropdownMenuTrigger, 243 + DropdownMenuContent, 244 + DropdownMenuGroup, 245 + DropdownMenuLabel, 246 + DropdownMenuItem, 247 + DropdownMenuCheckboxItem, 248 + DropdownMenuRadioGroup, 249 + DropdownMenuRadioItem, 250 + DropdownMenuSeparator, 251 + DropdownMenuShortcut, 252 + DropdownMenuSub, 253 + DropdownMenuSubTrigger, 254 + DropdownMenuSubContent, 255 + }
+29
src/components/ui/progress.tsx
··· 1 + import * as React from "react" 2 + import * as ProgressPrimitive from "@radix-ui/react-progress" 3 + 4 + import { cn } from "@/lib/utils" 5 + 6 + function Progress({ 7 + className, 8 + value, 9 + ...props 10 + }: React.ComponentProps<typeof ProgressPrimitive.Root>) { 11 + return ( 12 + <ProgressPrimitive.Root 13 + data-slot="progress" 14 + className={cn( 15 + "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", 16 + className 17 + )} 18 + {...props} 19 + > 20 + <ProgressPrimitive.Indicator 21 + data-slot="progress-indicator" 22 + className="bg-primary h-full w-full flex-1 transition-all" 23 + style={{ transform: `translateX(-${100 - (value || 0)}%)` }} 24 + /> 25 + </ProgressPrimitive.Root> 26 + ) 27 + } 28 + 29 + export { Progress }
+56
src/components/ui/scroll-area.tsx
··· 1 + import * as React from "react" 2 + import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 3 + 4 + import { cn } from "@/lib/utils" 5 + 6 + function ScrollArea({ 7 + className, 8 + children, 9 + ...props 10 + }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) { 11 + return ( 12 + <ScrollAreaPrimitive.Root 13 + data-slot="scroll-area" 14 + className={cn("relative", className)} 15 + {...props} 16 + > 17 + <ScrollAreaPrimitive.Viewport 18 + data-slot="scroll-area-viewport" 19 + className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1" 20 + > 21 + {children} 22 + </ScrollAreaPrimitive.Viewport> 23 + <ScrollBar /> 24 + <ScrollAreaPrimitive.Corner /> 25 + </ScrollAreaPrimitive.Root> 26 + ) 27 + } 28 + 29 + function ScrollBar({ 30 + className, 31 + orientation = "vertical", 32 + ...props 33 + }: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) { 34 + return ( 35 + <ScrollAreaPrimitive.ScrollAreaScrollbar 36 + data-slot="scroll-area-scrollbar" 37 + orientation={orientation} 38 + className={cn( 39 + "flex touch-none p-px transition-colors select-none", 40 + orientation === "vertical" && 41 + "h-full w-2.5 border-l border-l-transparent", 42 + orientation === "horizontal" && 43 + "h-2.5 flex-col border-t border-t-transparent", 44 + className 45 + )} 46 + {...props} 47 + > 48 + <ScrollAreaPrimitive.ScrollAreaThumb 49 + data-slot="scroll-area-thumb" 50 + className="bg-border relative flex-1 rounded-full" 51 + /> 52 + </ScrollAreaPrimitive.ScrollAreaScrollbar> 53 + ) 54 + } 55 + 56 + export { ScrollArea, ScrollBar }
+23
src/components/ui/sonner.tsx
··· 1 + import { useTheme } from "next-themes" 2 + import { Toaster as Sonner, ToasterProps } from "sonner" 3 + 4 + const Toaster = ({ ...props }: ToasterProps) => { 5 + const { theme = "system" } = useTheme() 6 + 7 + return ( 8 + <Sonner 9 + theme={theme as ToasterProps["theme"]} 10 + className="toaster group" 11 + style={ 12 + { 13 + "--normal-bg": "var(--popover)", 14 + "--normal-text": "var(--popover-foreground)", 15 + "--normal-border": "var(--border)", 16 + } as React.CSSProperties 17 + } 18 + {...props} 19 + /> 20 + ) 21 + } 22 + 23 + export { Toaster }
+176
src/lib/backup.ts
··· 1 + import { Agent } from "@atproto/api"; 2 + import { createBackupDir, getBackupDir } from "./paths"; 3 + import { join, resolve } from "@tauri-apps/api/path"; 4 + import { 5 + mkdir, 6 + readDir, 7 + readTextFile, 8 + writeFile, 9 + remove, 10 + } from "@tauri-apps/plugin-fs"; 11 + import { CarStats, getCarStats } from "./stats"; 12 + 13 + export interface Metadata { 14 + did: string; 15 + timestamp: string; 16 + backupType: string; 17 + filePath: string; 18 + stats: CarStats; 19 + } 20 + 21 + export class BackupManager { 22 + private agent: Agent; 23 + private maxBackups = 3; 24 + 25 + constructor(agent: Agent) { 26 + this.agent = agent; 27 + } 28 + 29 + async startBackup(): Promise<Metadata> { 30 + const did = this.agent.did; 31 + if (did == null) throw Error("Unauthenticated"); 32 + 33 + // Get the repo data 34 + const data = await this.agent.com.atproto.sync.getRepo({ did: did }); 35 + 36 + // Write to backup location 37 + const metadata = await this.writeBackupToFile(data.data, did); 38 + 39 + // Clean up old backups after creating new one 40 + await this.cleanupOldBackups(); 41 + 42 + return metadata; 43 + } 44 + 45 + private async writeBackupToFile( 46 + repoData: Uint8Array, 47 + did: string 48 + ): Promise<Metadata> { 49 + try { 50 + // Create backup directory structure 51 + await createBackupDir(); 52 + const backupDir = await getBackupDir(); 53 + const timestamp = new Date().toISOString().split("T")[0]; // YYYY-MM-DD 54 + const backupPath = await join(backupDir, `${timestamp}_backup`); 55 + await mkdir(backupPath); 56 + 57 + // Write the repo data as binary file 58 + const repoFilePath = await join(backupPath, "repo.car"); 59 + await writeFile(repoFilePath, repoData); 60 + 61 + const stats = await getCarStats(repoData); 62 + 63 + // Create a metadata file 64 + const metadata = { 65 + did: did, 66 + timestamp: new Date().toISOString(), 67 + backupType: "full_repo", 68 + filePath: repoFilePath, 69 + stats, 70 + }; 71 + 72 + const metadataPath = await join(backupPath, "metadata.json"); 73 + const metadataJson = JSON.stringify(metadata, null, 2); 74 + await writeFile(metadataPath, new TextEncoder().encode(metadataJson)); 75 + 76 + console.log(`Backup written to: ${backupPath}`); 77 + return metadata; 78 + } catch (error) { 79 + console.error("Failed to write backup:", error); 80 + throw error; 81 + } 82 + } 83 + 84 + private async cleanupOldBackups(): Promise<void> { 85 + try { 86 + const backups = await this.getBackups(); 87 + 88 + // Sort backups by timestamp (newest first) 89 + const sortedBackups = backups.sort( 90 + (a, b) => 91 + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() 92 + ); 93 + 94 + // If we have more than maxBackups, delete the oldest ones 95 + if (sortedBackups.length > this.maxBackups) { 96 + const backupsToDelete = sortedBackups.slice(this.maxBackups); 97 + 98 + for (const backup of backupsToDelete) { 99 + await this.deleteBackup(backup); 100 + } 101 + 102 + console.log(`Deleted ${backupsToDelete.length} old backup(s)`); 103 + } 104 + } catch (error) { 105 + console.error("Failed to cleanup old backups:", error); 106 + // Don't throw here - we don't want backup creation to fail because of cleanup issues 107 + } 108 + } 109 + 110 + private async deleteBackup(backup: Metadata): Promise<void> { 111 + try { 112 + const rootBackupDir = await getBackupDir(); 113 + const dir = await readDir(rootBackupDir); 114 + 115 + // Find the backup directory that contains this backup 116 + for (const backupDir of dir) { 117 + if (backupDir.isDirectory) { 118 + const backupPath = await resolve(rootBackupDir, backupDir.name); 119 + const metadataPath = await join(backupPath, "metadata.json"); 120 + 121 + try { 122 + const metadata = await readTextFile(metadataPath); 123 + const parsedMetadata = JSON.parse(metadata); 124 + 125 + // Check if this is the backup we want to delete 126 + if ( 127 + parsedMetadata.timestamp === backup.timestamp && 128 + parsedMetadata.did === backup.did 129 + ) { 130 + await remove(backupPath, { recursive: true }); 131 + console.log(`Deleted backup: ${backupPath}`); 132 + break; 133 + } 134 + } catch (e) { 135 + // Skip if we can't read metadata 136 + continue; 137 + } 138 + } 139 + } 140 + } catch (error) { 141 + console.error(`Failed to delete backup:`, error); 142 + } 143 + } 144 + 145 + async getBackups(): Promise<Metadata[]> { 146 + const data: Metadata[] = []; 147 + await createBackupDir(); 148 + const rootBackupDir = await getBackupDir(); 149 + const dir = await readDir(rootBackupDir); 150 + 151 + for (const backupDir of dir) { 152 + if (backupDir.isDirectory) { 153 + const backup = await readDir( 154 + await resolve(rootBackupDir, backupDir.name) 155 + ); 156 + const metadataFile = backup.find((e) => e.name == "metadata.json"); 157 + if (metadataFile) { 158 + try { 159 + const metadata = await readTextFile( 160 + await resolve(rootBackupDir, backupDir.name, metadataFile.name) 161 + ); 162 + data.push(JSON.parse(metadata)); 163 + } catch (error) { 164 + console.error( 165 + `Failed to read metadata for ${backupDir.name}:`, 166 + error 167 + ); 168 + // Continue processing other backups 169 + } 170 + } 171 + } 172 + } 173 + 174 + return data; 175 + } 176 + }
+20
src/lib/paths.ts
··· 1 + import { documentDir, resolve } from "@tauri-apps/api/path"; 2 + import { exists, BaseDirectory, mkdir } from "@tauri-apps/plugin-fs"; 3 + 4 + const dir = "ATBackup"; 5 + export async function getBackupDir() { 6 + const docs = await documentDir(); 7 + return await resolve(docs, dir); 8 + } 9 + 10 + export async function createBackupDir() { 11 + const dirExists = await exists(dir, { 12 + baseDir: BaseDirectory.Document, 13 + }); 14 + 15 + if (!dirExists) { 16 + await mkdir(dir, { 17 + baseDir: BaseDirectory.Document, 18 + }); 19 + } 20 + }
+60
src/lib/stats.ts
··· 1 + import { CarReader, RepoReader } from "@atcute/car/v4"; 2 + 3 + export interface CarStats { 4 + totalBlocks: number; 5 + totalSize: number; 6 + recordCount: number; 7 + recordTypes: Record<string, number>; 8 + fileSize: number; 9 + collections: string[]; 10 + createdAt: string; 11 + } 12 + 13 + export async function getCarStats(carData: Uint8Array): Promise<CarStats> { 14 + try { 15 + // Parse the CAR file 16 + await using repo = RepoReader.fromUint8Array(carData); 17 + 18 + let totalBlocks = 0; 19 + let totalSize = 0; 20 + let recordCount = 0; 21 + const recordTypes: Record<string, number> = {}; 22 + const collections = new Set<string>(); 23 + 24 + // Iterate through all blocks in the CAR file 25 + for await (const record of repo) { 26 + totalBlocks++; 27 + 28 + try { 29 + // Try to decode the block as a record 30 + if (record) { 31 + // Count different record types 32 + const type = (record.record as any)["$type"] 33 + if (type) { 34 + recordTypes[type] = (recordTypes[type] || 0) + 1; 35 + recordCount++; 36 + 37 + // Extract collection name 38 + collections.add(type); 39 + } 40 + } 41 + } catch (e) { 42 + // Not all blocks are records, some are structural 43 + continue; 44 + } 45 + } 46 + 47 + return { 48 + totalBlocks, 49 + totalSize, 50 + recordCount, 51 + recordTypes, 52 + fileSize: carData.length, 53 + collections: Array.from(collections), 54 + createdAt: new Date().toISOString(), 55 + }; 56 + } catch (error) { 57 + console.error("Error parsing CAR file:", error); 58 + throw new Error(`Failed to parse CAR file: ${error}`); 59 + } 60 + }
+123
src/localstorage_ployfill.ts
··· 1 + // localStorage-polyfill.ts 2 + import { Store } from "@tauri-apps/plugin-store"; 3 + 4 + interface StorageData { 5 + [key: string]: string; 6 + } 7 + 8 + class LocalStoragePolyfill implements Storage { 9 + private store: Store; 10 + private cache: Map<string, string>; 11 + private initialized: boolean = false; 12 + private initPromise: Promise<void> | null = null; 13 + 14 + constructor(store: Store) { 15 + this.store = store; 16 + this.cache = new Map(); 17 + } 18 + 19 + async init(): Promise<void> { 20 + if (this.initialized) return; 21 + 22 + if (this.initPromise) { 23 + return this.initPromise; 24 + } 25 + 26 + this.initPromise = this._initialize(); 27 + return this.initPromise; 28 + } 29 + 30 + private async _initialize(): Promise<void> { 31 + try { 32 + const data = (await this.store.get("data")) as StorageData | null; 33 + if (data && typeof data === "object") { 34 + Object.entries(data).forEach(([key, value]) => { 35 + this.cache.set(key, value); 36 + }); 37 + } 38 + } catch (error) { 39 + console.error("Failed to load localStorage data:", error); 40 + } 41 + 42 + this.initialized = true; 43 + } 44 + 45 + private async persist(): Promise<void> { 46 + try { 47 + const data: StorageData = Object.fromEntries(this.cache); 48 + await this.store.set("data", data); 49 + await this.store.save(); 50 + } catch (error) { 51 + console.error("Failed to persist localStorage data:", error); 52 + } 53 + } 54 + 55 + getItem(key: string): string | null { 56 + return this.cache.get(key) || null; 57 + } 58 + 59 + setItem(key: string, value: string): void { 60 + this.cache.set(key, String(value)); 61 + // Persist asynchronously to avoid blocking 62 + this.persist().catch(console.error); 63 + } 64 + 65 + removeItem(key: string): void { 66 + this.cache.delete(key); 67 + this.persist().catch(console.error); 68 + } 69 + 70 + clear(): void { 71 + this.cache.clear(); 72 + this.persist().catch(console.error); 73 + } 74 + 75 + get length(): number { 76 + return this.cache.size; 77 + } 78 + 79 + key(index: number): string | null { 80 + return Array.from(this.cache.keys())[index] || null; 81 + } 82 + 83 + // Required for Storage interface compatibility 84 + [name: string]: any; 85 + } 86 + 87 + // Initialize and replace global localStorage 88 + let tauriLocalStorage: LocalStoragePolyfill | undefined; 89 + 90 + export const initializeLocalStorage = async (): Promise<void> => { 91 + if (typeof window !== "undefined") { 92 + const store = await Store.load("localStorage.json"); 93 + tauriLocalStorage = new LocalStoragePolyfill(store); 94 + await tauriLocalStorage.init(); 95 + 96 + // Try to replace localStorage using Object.defineProperty 97 + try { 98 + Object.defineProperty(window, "localStorage", { 99 + value: tauriLocalStorage, 100 + writable: true, 101 + configurable: true, 102 + }); 103 + } catch (error) { 104 + console.warn("Could not replace global localStorage:", error); 105 + // Fallback: store reference for manual usage 106 + (window as any).__tauriLocalStorage = tauriLocalStorage; 107 + } 108 + } 109 + }; 110 + 111 + export const getTauriLocalStorage = (): LocalStoragePolyfill | undefined => { 112 + return tauriLocalStorage; 113 + }; 114 + 115 + // Helper function to get localStorage (either polyfill or native) 116 + export const getLocalStorage = (): Storage => { 117 + if (typeof window !== "undefined") { 118 + return window.localStorage || (window as any).__tauriLocalStorage; 119 + } 120 + throw new Error("localStorage not available"); 121 + }; 122 + 123 + export default tauriLocalStorage;
+419
src/routes/Home.tsx
··· 1 + import { Button } from "@/components/ui/button"; 2 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 3 + import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 4 + import { 5 + DropdownMenu, 6 + DropdownMenuContent, 7 + DropdownMenuItem, 8 + DropdownMenuLabel, 9 + DropdownMenuSeparator, 10 + DropdownMenuTrigger, 11 + } from "@/components/ui/dropdown-menu"; 12 + import { openPath } from "@tauri-apps/plugin-opener"; 13 + import { appDataDir, documentDir } from "@tauri-apps/api/path"; 14 + import { createBackupDir, getBackupDir } from "@/lib/paths"; 15 + import { useEffect, useState, useRef } from "react"; 16 + import { 17 + History, 18 + LoaderCircleIcon, 19 + ChevronDown, 20 + ChevronUp, 21 + FolderOpen, 22 + Download, 23 + Database, 24 + FileText, 25 + HardDrive, 26 + Calendar, 27 + Package, 28 + Zap, 29 + Heart, 30 + Users, 31 + User, 32 + } from "lucide-react"; 33 + import { BackupManager, Metadata } from "@/lib/backup"; 34 + import { useAuth } from "@/Auth"; 35 + import { toast } from "sonner"; 36 + import { Badge } from "@/components/ui/badge"; 37 + import { 38 + Card, 39 + CardContent, 40 + CardDescription, 41 + CardHeader, 42 + CardTitle, 43 + } from "@/components/ui/card"; 44 + import { Progress } from "@/components/ui/progress"; 45 + 46 + export function Home({ 47 + profile, 48 + onLogout, 49 + }: { 50 + profile: ProfileViewDetailed; 51 + onLogout: () => void; 52 + }) { 53 + const [isDirLoading, setDirLoading] = useState(false); 54 + const [refreshTrigger, setRefreshTrigger] = useState(0); 55 + 56 + const handleBackupComplete = () => { 57 + setRefreshTrigger((prev) => prev + 1); 58 + }; 59 + 60 + return ( 61 + <div className="p-4 mt-10"> 62 + <div className="flex justify-between items-center mb-4"> 63 + <div> 64 + <DropdownMenu> 65 + <DropdownMenuTrigger asChild> 66 + <div className="flex flex-row items-center gap-2 cursor-pointer p-2 rounded-md transition-colors hover:bg-white/10"> 67 + <Avatar> 68 + <AvatarImage src={profile.avatar} /> 69 + <AvatarFallback> 70 + {profile.displayName ?? profile.handle} 71 + </AvatarFallback> 72 + </Avatar> 73 + <span className="text-white"> 74 + {profile.displayName ?? `@${profile.handle}`} 75 + </span> 76 + </div> 77 + </DropdownMenuTrigger> 78 + 79 + <DropdownMenuContent className="ml-3"> 80 + <DropdownMenuItem 81 + onClick={onLogout} 82 + className="cursor-pointer text-red-500" 83 + > 84 + Log out 85 + </DropdownMenuItem> 86 + </DropdownMenuContent> 87 + </DropdownMenu> 88 + </div> 89 + </div> 90 + 91 + <div className="bg-card rounded-lg p-4 mb-4"> 92 + <p className="mb-2 text-white">Backups</p> 93 + <div className="flex gap-2"> 94 + <Button 95 + variant="outline" 96 + className="cursor-pointer" 97 + onClick={async () => { 98 + try { 99 + setDirLoading(true); 100 + await createBackupDir(); 101 + const appDataDirPath = await getBackupDir(); 102 + openPath(appDataDirPath); 103 + } finally { 104 + setDirLoading(false); 105 + } 106 + }} 107 + disabled={isDirLoading} 108 + > 109 + {isDirLoading ? ( 110 + <LoaderCircleIcon className="w-4 h-4 animate-spin mr-2" /> 111 + ) : ( 112 + <FolderOpen className="w-4 h-4 mr-2" /> 113 + )} 114 + Open backups 115 + </Button> 116 + 117 + <StartBackup onBackupComplete={handleBackupComplete} /> 118 + </div> 119 + </div> 120 + 121 + <Backups refreshTrigger={refreshTrigger} /> 122 + </div> 123 + ); 124 + } 125 + 126 + function StartBackup({ onBackupComplete }: { onBackupComplete: () => void }) { 127 + const [isLoading, setIsLoading] = useState(false); 128 + const { agent } = useAuth(); 129 + 130 + return ( 131 + <Button 132 + variant="outline" 133 + className="cursor-pointer" 134 + onClick={async () => { 135 + try { 136 + setIsLoading(true); 137 + if (agent == null) { 138 + toast("Agent not initialized, try to reload the app."); 139 + return; 140 + } 141 + const manager = new BackupManager(agent!!); 142 + await manager.startBackup(); 143 + toast("Backup complete!"); 144 + onBackupComplete(); // Trigger refresh 145 + } catch (err: any) { 146 + toast(err.toString()); 147 + console.error(err); 148 + } finally { 149 + setIsLoading(false); 150 + } 151 + }} 152 + disabled={isLoading} 153 + > 154 + {isLoading ? ( 155 + <LoaderCircleIcon className="animate-spin text-white/80" /> 156 + ) : ( 157 + <span>Backup now</span> 158 + )} 159 + </Button> 160 + ); 161 + } 162 + 163 + function formatBytes(bytes: number) { 164 + if (bytes < 1024) return `${bytes} B`; 165 + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; 166 + if (bytes < 1024 * 1024 * 1024) 167 + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; 168 + return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; 169 + } 170 + 171 + function getRecordTypeIcon(type: string) { 172 + if (type.includes("post")) return <FileText className="w-4 h-4" />; 173 + if (type.includes("like")) return <Heart className="w-4 h-4" />; 174 + if (type.includes("follow")) return <Users className="w-4 h-4" />; 175 + if (type.includes("profile")) return <User className="w-4 h-4" />; 176 + return <Package className="w-4 h-4" />; 177 + } 178 + 179 + function Backups({ refreshTrigger }: { refreshTrigger: number }) { 180 + const [isLoading, setIsLoading] = useState(false); 181 + const [backups, setBackups] = useState<Metadata[]>([]); 182 + const [showCollections, setShowCollections] = useState< 183 + Record<string, boolean> 184 + >({}); 185 + const { agent } = useAuth(); 186 + const contentRefs = useRef<Record<string, HTMLDivElement | null>>({}); 187 + 188 + const loadBackups = async () => { 189 + if (agent == null) { 190 + return; 191 + } 192 + setIsLoading(true); 193 + try { 194 + const manager = new BackupManager(agent); 195 + const backupsList = await manager.getBackups(); 196 + // Sort backups by timestamp (newest first) 197 + const sortedBackups = backupsList.sort( 198 + (a, b) => 199 + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() 200 + ); 201 + setBackups(sortedBackups); 202 + } catch (err: any) { 203 + toast(err.toString()); 204 + console.error(err); 205 + } finally { 206 + setIsLoading(false); 207 + } 208 + }; 209 + 210 + useEffect(() => { 211 + loadBackups(); 212 + }, [agent, refreshTrigger]); // Add refreshTrigger to dependencies 213 + 214 + //@ts-expect-error 215 + const units: Record<Intl.RelativeTimeFormatUnit, number> = { 216 + year: 24 * 60 * 60 * 1000 * 365, 217 + month: (24 * 60 * 60 * 1000 * 365) / 12, 218 + day: 24 * 60 * 60 * 1000, 219 + hour: 60 * 60 * 1000, 220 + minute: 60 * 1000, 221 + second: 1000, 222 + }; 223 + 224 + const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); 225 + 226 + const getRelativeTime = (d1: Date, d2 = new Date()) => { 227 + const elapsed = d1.getTime() - d2.getTime(); 228 + for (const u in units) { 229 + if (Math.abs(elapsed) > units[u as keyof typeof units] || u == "second") { 230 + return rtf.format( 231 + Math.round(elapsed / units[u as keyof typeof units]), 232 + u as Intl.RelativeTimeFormatUnit 233 + ); 234 + } 235 + } 236 + }; 237 + 238 + const getTotalRecords = (backup: Metadata) => { 239 + return Object.values(backup.stats?.recordTypes || {}).reduce( 240 + (sum, count) => sum + count, 241 + 0 242 + ); 243 + }; 244 + 245 + if (isLoading) { 246 + return ( 247 + <div className="bg-card rounded-lg p-4"> 248 + <div className="flex items-center gap-2 mb-4"> 249 + <History className="w-5 h-5" /> 250 + <p className="text-white text-lg font-semibold">Previous backups</p> 251 + </div> 252 + <div className="flex items-center justify-center py-8"> 253 + <LoaderCircleIcon className="w-6 h-6 animate-spin text-white/80" /> 254 + </div> 255 + </div> 256 + ); 257 + } 258 + 259 + return ( 260 + <div className="bg-card rounded-lg p-4"> 261 + <div className="flex items-center gap-2 mb-4"> 262 + <History className="w-5 h-5" /> 263 + <p className="text-white text-lg font-semibold">Previous backups</p> 264 + </div> 265 + 266 + {backups.length === 0 ? ( 267 + <div className="text-muted-foreground text-sm">No backups found.</div> 268 + ) : ( 269 + <div className="space-y-4"> 270 + {backups.map((backup, index) => { 271 + const expanded = !!showCollections[backup.filePath]; 272 + const totalRecords = getTotalRecords(backup); 273 + const recordTypes = Object.entries(backup.stats?.recordTypes || {}); 274 + 275 + return ( 276 + <div 277 + key={backup.filePath} 278 + className="rounded-lg border border-white/10 shadow-sm" 279 + > 280 + <div 281 + className="cursor-pointer select-none hover:bg-white/5 transition-colors p-4 rounded-lg" 282 + onClick={() => 283 + setShowCollections((prev) => ({ 284 + ...prev, 285 + [backup.filePath]: !prev[backup.filePath], 286 + })) 287 + } 288 + > 289 + <div className="flex items-center justify-between mb-4"> 290 + <div className="flex items-center gap-3"> 291 + {index === 0 && <Badge variant="default">Newest</Badge>} 292 + <div className="flex items-center gap-2 text-white/80"> 293 + <span> 294 + {getRelativeTime(new Date(backup.timestamp))} ( 295 + {new Date(backup.timestamp).toLocaleString()}) 296 + </span> 297 + <span>•</span> 298 + <span>{formatBytes(backup.stats?.fileSize || 0)}</span> 299 + <span>•</span> 300 + <span className="font-semibold text-white/90"> 301 + {backup.stats?.collections.length || 0} collections 302 + </span> 303 + </div> 304 + </div> 305 + <div 306 + className={`transition-transform duration-300 ${ 307 + expanded ? "rotate-180" : "rotate-0" 308 + }`} 309 + > 310 + <ChevronDown className="w-5 h-5 text-white/60" /> 311 + </div> 312 + </div> 313 + 314 + <div className="grid grid-cols-1 md:grid-cols-4 gap-4"> 315 + <div className="flex items-center gap-2 p-3 rounded-lg bg-white/5"> 316 + <FileText className="w-5 h-5 text-blue-400" /> 317 + <div> 318 + <p className="text-white/60 text-xs">Records</p> 319 + <p className="text-white font-semibold"> 320 + {totalRecords.toLocaleString()} 321 + </p> 322 + </div> 323 + </div> 324 + 325 + <div className="flex items-center gap-2 p-3 rounded-lg bg-white/5"> 326 + <Package className="w-5 h-5 text-orange-400" /> 327 + <div> 328 + <p className="text-white/60 text-xs">Collections</p> 329 + <p className="text-white font-semibold"> 330 + {backup.stats.collections.length.toLocaleString()} 331 + </p> 332 + </div> 333 + </div> 334 + 335 + <div className="flex items-center gap-2 p-3 rounded-lg bg-white/5"> 336 + <Database className="w-5 h-5 text-purple-400" /> 337 + <div> 338 + <p className="text-white/60 text-xs">Blocks</p> 339 + <p className="text-white font-semibold"> 340 + {backup.stats?.totalBlocks?.toLocaleString() || "-"} 341 + </p> 342 + </div> 343 + </div> 344 + 345 + <div className="flex items-center gap-2 p-3 rounded-lg bg-white/5"> 346 + <HardDrive className="w-5 h-5 text-green-400" /> 347 + <div> 348 + <p className="text-white/60 text-xs">File Size</p> 349 + <p className="text-white font-semibold"> 350 + {formatBytes(backup.stats?.fileSize || 0)} 351 + </p> 352 + </div> 353 + </div> 354 + </div> 355 + </div> 356 + 357 + <div 358 + ref={(el) => { 359 + contentRefs.current[backup.filePath] = el; 360 + }} 361 + style={{ 362 + maxHeight: 363 + expanded && contentRefs.current[backup.filePath] 364 + ? `${ 365 + contentRefs.current[backup.filePath]!.scrollHeight 366 + }px` 367 + : 0, 368 + opacity: expanded ? 1 : 0, 369 + transition: 370 + "max-height 0.4s cubic-bezier(0.4,0,0.2,1), opacity 0.3s", 371 + overflow: "hidden", 372 + }} 373 + > 374 + <div className="border-t border-white/10 mx-4 mt-4" /> 375 + <div className="pt-4 p-4"> 376 + <h4 className="text-white font-medium mb-3 flex items-center gap-2"> 377 + <Package className="w-4 h-4" /> 378 + Record Types 379 + </h4> 380 + <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> 381 + {recordTypes.map(([type, count]) => { 382 + const percentage = 383 + totalRecords > 0 ? (count / totalRecords) * 100 : 0; 384 + return ( 385 + <div 386 + key={type} 387 + className="p-3 rounded-lg bg-white/5 border border-white/10" 388 + > 389 + <div className="flex items-center justify-between mb-2"> 390 + <div className="flex items-center gap-2"> 391 + {getRecordTypeIcon(type)} 392 + <span className="text-white/80 text-sm font-medium"> 393 + {type} 394 + </span> 395 + </div> 396 + <div className="flex items-center gap-2"> 397 + <span className="text-white font-semibold"> 398 + {count.toLocaleString()} 399 + </span> 400 + <span className="text-white/60 text-xs"> 401 + ({percentage.toFixed(1)}%) 402 + </span> 403 + </div> 404 + </div> 405 + <Progress value={percentage} className="h-2" /> 406 + </div> 407 + ); 408 + })} 409 + </div> 410 + </div> 411 + </div> 412 + </div> 413 + ); 414 + })} 415 + </div> 416 + )} 417 + </div> 418 + ); 419 + }
+43 -17
src/routes/Login.tsx
··· 8 8 CredentialSession, 9 9 AtpSessionData, 10 10 } from "@atproto/api"; 11 - import { 12 - BrowserOAuthClient, 13 - BrowserOAuthClientOptions, 14 - OAuthSession, 15 - } from "@atproto/oauth-client-browser"; 16 11 import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; 12 + import { openUrl } from "@tauri-apps/plugin-opener"; 13 + import { OAuthClient, type OAuthSession } from "@atproto/oauth-client"; 14 + import { SquareArrowOutUpRight } from "lucide-react"; 17 15 18 16 type LoginMethod = "credential" | "oauth"; 19 17 20 18 interface LoginPageProps { 21 19 onLogin: (session: OAuthSession) => void; 22 - client: BrowserOAuthClient; 20 + client: OAuthClient | null; 23 21 } 24 22 25 23 export default function LoginPage({ ··· 55 53 // Get the first URL from the array and parse it 56 54 const url = new URL(urls[0]); 57 55 56 + console.log("OAuth callback URL:", url.searchParams.entries); 58 57 // Process the OAuth callback with the URLSearchParams directly 59 58 const session = await oauthClient.callback(url.searchParams); 60 59 console.log("OAuth callback successful!", session); ··· 63 62 } catch (err) { 64 63 console.error("Failed to process OAuth callback:", err); 65 64 setError("Failed to complete OAuth login"); 65 + setLoading(false); 66 66 } finally { 67 67 processingOAuthRef.current = false; 68 68 } ··· 72 72 } 73 73 }; 74 74 initOAuthClient(); 75 - }, [onLogin]); 75 + }, [onLogin, oauthClient]); 76 76 77 77 const handleLogin = async () => { 78 78 if (!oauthClient) { ··· 90 90 return; 91 91 } 92 92 93 - // Sign in using OAuth with popup 94 - const session = await oauthClient.signInPopup(identifier, { 93 + const url = await oauthClient.authorize(identifier, { 95 94 scope: "atproto transition:generic", 96 95 ui_locales: "en", 97 96 signal: new AbortController().signal, 98 97 }); 99 98 100 - console.log("OAuth login successful!", session); 101 - onLogin(session); 99 + await openUrl(url); 102 100 } catch (err: any) { 103 101 console.error(err); 104 102 setError(err.message || "OAuth login failed"); 105 - } finally { 106 103 setLoading(false); 107 104 } 108 105 }; 109 106 110 107 return ( 111 - <div className="min-h-screen flex items-center justify-center bg-background px-4"> 112 - <Card className="w-full max-w-sm"> 108 + <div 109 + className="min-h-screen flex items-center justify-center bg-background px-4 relative" 110 + style={{ 111 + backgroundImage: "url(/src/assets/milky_way.jpg)", 112 + backgroundSize: "300%", 113 + backgroundPosition: "center", 114 + backgroundRepeat: "no-repeat", 115 + }} 116 + > 117 + <Card className="w-full max-w-sm bg-black/50 backdrop-blur-md"> 113 118 <CardHeader> 114 - <CardTitle>Login with your handle on the Atmosphere</CardTitle> 119 + <CardTitle className="cursor-default"> 120 + Login with your handle on the Atmosphere 121 + </CardTitle> 115 122 </CardHeader> 116 123 <CardContent className="space-y-4"> 117 124 <Input 118 - placeholder="Username or email" 125 + placeholder="example.bsky.social" 119 126 value={identifier} 120 127 onChange={(e) => setIdentifier(e.target.value)} 121 128 /> ··· 123 130 <Button 124 131 className="w-full cursor-pointer" 125 132 onClick={handleLogin} 126 - disabled={loading || identifier == null} 133 + disabled={loading || !identifier || !oauthClient} 127 134 > 128 135 {loading ? "Logging in..." : "Login"} 129 136 </Button> 130 137 </CardContent> 131 138 </Card> 139 + <div 140 + className="absolute left-0 bottom-0 m-4 text-xs text-white/60 flex flex-row items-center gap-1" 141 + style={{ pointerEvents: "auto" }} 142 + > 143 + <button 144 + type="button" 145 + className="gap-2 p-0 bg-transparent border-none text-inherit hover:underline flex items-center cursor-pointer" 146 + onClick={() => 147 + openUrl( 148 + "https://commons.wikimedia.org/wiki/File:Bontecou_Lake_Milky_Way_panorama.jpg" 149 + ) 150 + } 151 + tabIndex={0} 152 + aria-label="Open image in browser" 153 + > 154 + <span>Image by Juliancolton, CC BY-SA 4.0</span> 155 + <SquareArrowOutUpRight size={14} /> 156 + </button> 157 + </div> 132 158 </div> 133 159 ); 134 160 }
+73
src/theme-provider.tsx
··· 1 + import { createContext, useContext, useEffect, useState } from "react"; 2 + 3 + type Theme = "dark" | "light" | "system"; 4 + 5 + type ThemeProviderProps = { 6 + children: React.ReactNode; 7 + defaultTheme?: Theme; 8 + storageKey?: string; 9 + }; 10 + 11 + type ThemeProviderState = { 12 + theme: Theme; 13 + setTheme: (theme: Theme) => void; 14 + }; 15 + 16 + const initialState: ThemeProviderState = { 17 + theme: "system", 18 + setTheme: () => null, 19 + }; 20 + 21 + const ThemeProviderContext = createContext<ThemeProviderState>(initialState); 22 + 23 + export function ThemeProvider({ 24 + children, 25 + defaultTheme = "system", 26 + storageKey = "vite-ui-theme", 27 + ...props 28 + }: ThemeProviderProps) { 29 + const [theme, setTheme] = useState<Theme>( 30 + () => (localStorage.getItem(storageKey) as Theme) || defaultTheme 31 + ); 32 + 33 + useEffect(() => { 34 + const root = window.document.documentElement; 35 + 36 + root.classList.remove("light", "dark"); 37 + 38 + if (theme === "system") { 39 + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") 40 + .matches 41 + ? "dark" 42 + : "light"; 43 + 44 + root.classList.add(systemTheme); 45 + return; 46 + } 47 + 48 + root.classList.add(theme); 49 + }, [theme]); 50 + 51 + const value = { 52 + theme, 53 + setTheme: (theme: Theme) => { 54 + localStorage.setItem(storageKey, theme); 55 + setTheme(theme); 56 + }, 57 + }; 58 + 59 + return ( 60 + <ThemeProviderContext.Provider {...props} value={value}> 61 + {children} 62 + </ThemeProviderContext.Provider> 63 + ); 64 + } 65 + 66 + export const useTheme = () => { 67 + const context = useContext(ThemeProviderContext); 68 + 69 + if (context === undefined) 70 + throw new Error("useTheme must be used within a ThemeProvider"); 71 + 72 + return context; 73 + };