### **PR Type** Enhancement ___ ### **Description** - Add cross-framework Todos CRUD examples - Introduce file upload/download tutorials - Provide unified AuthProvider context implementations - Include email templates and backend actions ___ <details> <summary><h3> File Walkthrough</h3></summary> <table><thead><tr><th></th><th align="left">Relevant files</th></tr></thead><tbody><tr><td><strong>Enhancement</strong></td><td><details><summary>19 files</summary><table> <tr> <td><strong>Todos.tsx</strong><dd><code>Add web demo Todos CRUD page</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-53f3b3d582fef21d5ec90cb590b73afcf09407071dba60883ed1ed7360955fc5">+648/-0</a> </td> </tr> <tr> <td><strong>todos.tsx</strong><dd><code>Add React Native tutorial Todos</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3f6be5ef4f443091687addd404fe71f219498b9db7dea992d18d78b4f1b6ffa3">+561/-0</a> </td> </tr> <tr> <td><strong>commonStyles.ts</strong><dd><code>Add common React Native tutorial styles</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-bf0de16179ecc80a8e88e223c890dc2c73c30b4a9b7cadd62e910ca015ce342b">+667/-0</a> </td> </tr> <tr> <td><strong>Todos.tsx</strong><dd><code>Add React tutorial Todos page</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-be700e4847b0a745821f156c381e583097f2083123065a45a20611c2ba1876a7">+504/-0</a> </td> </tr> <tr> <td><strong>files.tsx</strong><dd><code>Add React Native tutorial file upload</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-d116805053a943f271a3297d06d14dba39c0f5775080e67e1e9e2778c176e9da">+454/-0</a> </td> </tr> <tr> <td><strong>Files.tsx</strong><dd><code>Add React tutorial Files page</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-cdbca4ed68690df84463df7765dff52c85a60502f175c19519c8b42474e9282c">+404/-0</a> </td> </tr> <tr> <td><strong>signup.tsx</strong><dd><code>Enhance signup flow with email verification</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-262b09b9dd7234cad96fe092d7131a23451c9e50b98c126c9e36599b3a127ac6">+185/-141</a></td> </tr> <tr> <td><strong>FilesClient.tsx</strong><dd><code>Add Next.js Files client component</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ea74386f232b9ae7e7957ab4eb1f0d1d6076b338173e8b1e917369fb7f1b39bb">+359/-0</a> </td> </tr> <tr> <td><strong>TodosClient.tsx</strong><dd><code>Add Next.js Todos client component</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-96efb4db6bd61a7faee3c383e77b092eccee1a1876770c36691e7356268cfad4">+368/-0</a> </td> </tr> <tr> <td><strong>AuthProvider.tsx</strong><dd><code>Implement React AuthProvider context</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-4cc7e41420d71d448eeec4f77043e0c5bff2c606986439454dade5ffcd433e33">+174/-0</a> </td> </tr> <tr> <td><strong>AuthProvider.tsx</strong><dd><code>Implement RN AuthProvider context</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-93f9f27a35d0039a64cd6889a296d04d37542aa5a777925e61e8e60ee5a6d744">+148/-0</a> </td> </tr> <tr> <td><strong>actions.ts</strong><dd><code>Add Next.js server actions for Todos</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-76d57097940d3043c8e0ab29761767861b78fe86ab8a90a2d8209f1818131d31">+223/-0</a> </td> </tr> <tr> <td><strong>signup.tsx</strong><dd><code>Add RN signup tutorial screen</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-153c2f0bf6b3744bb84b95e356dd78c8771206a1b22218bc4c6f90641e4143ad">+183/-0</a> </td> </tr> <tr> <td><strong>explore.tsx</strong><dd><code>Add RN tutorial Explore screen</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-980313b6c7f75f2ecd45fc476895bea122364d000f5988e21564bf5db73d7f57">+125/-0</a> </td> </tr> <tr> <td><strong>route.ts</strong><dd><code>Add Next.js file download route</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5c39cd02d478ad625a7cdb6df3f7b6d20a76f40488636fdd87282c66174b2bd8">+57/-0</a> </td> </tr> <tr> <td><strong>SignUp.tsx</strong><dd><code>Enhance demo signup with verification state</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-75657efa0e1c29f59692ced3cd90e9c734836977900dc64015dd5d217bb263da">+38/-3</a> </td> </tr> <tr> <td><strong>SignUpForm.tsx</strong><dd><code>Add Next.js signup client form</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-9034cd412c47033a01e604a8250984aa1d1ecefc9884b79ebd7f7f3af17e3167">+89/-0</a> </td> </tr> <tr> <td><strong>SignInForm.tsx</strong><dd><code>Add Next.js signin client form</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-c276aadee4507d515832909164d447c3c1a4870277d0adbd1f7e836f7c66259e">+75/-0</a> </td> </tr> <tr> <td><strong>server.tsx</strong><dd><code>Add server Nhost client for Next.js</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-9d650defc223584e4aa06ddbf1d9a97c47a5a7ec4c9589a72ac7ea5369853400">+89/-0</a> </td> </tr> </table></details></td></tr><tr><td><strong>Configuration changes</strong></td><td><details><summary>1 files</summary><table> <tr> <td><strong>_layout.tsx</strong><dd><code>Configure RN tutorial tab navigation</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-c3b0bac088aef9f1a2d5cd2f4b51dd19fb301034668a062860a1e6a3512c15c9">+39/-0</a> </td> </tr> </table></details></td></tr><tr><td><strong>Additional files</strong></td><td><details><summary>101 files</summary><table> <tr> <td><strong>examples_tutorials_checks.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-745712dc409289d30fe4caa74de567df9cc9fac97750ae5566f93a51e48bf539">+94/-0</a> </td> </tr> <tr> <td><strong>docs.json</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-873ce17c654718debe2fe308a2f2279bde8663686423c51f97fab2dd0722b8d9">+52/-7</a> </td> </tr> <tr> <td><strong>overview.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8c9b35da559a5de5fe14ee078573e8d487453e26ed760c03ffd7f0ad476ca24d">+5/-5</a> </td> </tr> <tr> <td><strong>nextjs.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a5210d45e7d33a57d43078dbe2a2ccbf0667b157291fd92c3986092d7d33ab9c">+0/-508</a> </td> </tr> <tr> <td><strong>1-introduction.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-35413705a524a3eb2c7d096daef02d660b479fbc288674aa260e3f159988652b">+116/-0</a> </td> </tr> <tr> <td><strong>2-protected-routes.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-d901b3a6d8a96e3070f27afb934c34365aa79aeee1505238de2cb77a9ffd8546">+1364/-0</a></td> </tr> <tr> <td><strong>3-user-authentication.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-7b96576ad1b1b6b8e50dd3889e24df391662df1b7b51a111f0239f655135939f">+804/-0</a> </td> </tr> <tr> <td><strong>4-graphql-operations.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-16906637a292d22c018167d45f2da67a17fe73155ca161de91c3b329fee9d399">+958/-0</a> </td> </tr> <tr> <td><strong>5-file-uploads.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-d6153781dbf251499f2ffeeb707102349776d788859c2da0f63c8b0a8e35c821">+822/-0</a> </td> </tr> <tr> <td><strong>react.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-6f5adda9f7b29d98c68cab6ec754c0bac501666a49dd635ee830789e2c812b68">+0/-497</a> </td> </tr> <tr> <td><strong>1-introduction.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-4dac916b2b27af668674965d9680f4ba8d2a417f7b155d4b052b1043fc71beb6">+116/-0</a> </td> </tr> <tr> <td><strong>2-protected-routes.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8ab0206ae4a4c73e540cf549fdbdd9de4ed7c8d59cc88ea26a4a7ddc0a9a4460">+1435/-0</a></td> </tr> <tr> <td><strong>3-user-authentication.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-e02466ba1abd5d457724c7508da083a94b16d700cab36384567592c7d772cbf6">+647/-0</a> </td> </tr> <tr> <td><strong>4-graphql-operations.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ae759670f7bf513c66bc08952556514e7193f23eb7cc536aebf38ad79e12c02f">+856/-0</a> </td> </tr> <tr> <td><strong>5-file-uploads.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-02dc5fa1da67b0478bb797ed99c89685be3ec4509023441fb29b730ba74fff4e">+715/-0</a> </td> </tr> <tr> <td><strong>reactnative.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a85b3aa1caaeaa0a2cb6219ca531f89aaf2b23b41cd424c189b3cb948af1fd57">+0/-443</a> </td> </tr> <tr> <td><strong>1-introduction.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-573f696fb008f0e047e97209a1595b1f5c69dd5a1fe4907b65d6883273c53096">+121/-0</a> </td> </tr> <tr> <td><strong>2-protected-routes.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-b4ab0ba037b62e9eb5ee10cf5f56fd1c148f1e053f95291e42113d9f924ae711">+1499/-0</a></td> </tr> <tr> <td><strong>3-user-authentication.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-dda89bc27be58e7fa58aef9aeb937dce455f69d9566e734a10ef4e0df993ab42">+805/-0</a> </td> </tr> <tr> <td><strong>4-graphql-operations.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-59a46fa74310dc797cdfd6e1a34d09fd8002e9accdade9d865b898419addc2bc">+881/-0</a> </td> </tr> <tr> <td><strong>5-file-uploads.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-cb48bca7fdd422250b2482167b9a136d76ab60d2d5efccb94fb1a022b814d449">+776/-0</a> </td> </tr> <tr> <td><strong>6-sign-in-with-apple.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a4da2a574e88122505df17fcf1f9705841a8240b33f60b6fcde86e068b8b6cfe">+684/-0</a> </td> </tr> <tr> <td><strong>1-introduction.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-1eb8289a9ffc0484d2f8f21ede882c9e892d169f36b69f2ed9af940e2d1b8faa">+116/-0</a> </td> </tr> <tr> <td><strong>2-protected-routes.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-bff6efc64eeda0dae0d2a133ab6c9be9dfe725ee24b364b89060ec93344ad436">+1308/-0</a></td> </tr> <tr> <td><strong>3-user-authentication.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-f6bac1bb9476f921272f61911c703529663fb6ff37217c38d1702a1c8b70171a">+578/-0</a> </td> </tr> <tr> <td><strong>4-graphql-operations.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3a43d1ebc0b4fd7f74699c4f33ea110bba5ffa2695e6228dccafbfa38798c633">+763/-0</a> </td> </tr> <tr> <td><strong>5-file-uploads.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-2244e838acdbe909772f82f66cc63800aa9dbf5fae292a40d2d4f663d6618dd4">+642/-0</a> </td> </tr> <tr> <td><strong>sveltekit.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-9ed4ad735d1142a65a2da2dbbd8d46c508b7aef3d032cdb102d0f329602c4ce1">+0/-497</a> </td> </tr> <tr> <td><strong>vue.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-f6c4215fa6909fd3accebe0691a7364d17befb8ef90da5a4aeaee83d598c0540">+0/-504</a> </td> </tr> <tr> <td><strong>1-introduction.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-9485f8a6ad6398b19aa7a3739af3eef5158a7d285dc5a55e8bc6c1d58970fb84">+116/-0</a> </td> </tr> <tr> <td><strong>2-protected-routes.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5ba36ef4388ad18b7efc6d405e2b66a69b063d483d4521d6acb5b5da65aee97a">+1368/-0</a></td> </tr> <tr> <td><strong>3-user-authentication.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-63f56a07c48f78462b47f74420e58cb8411d46aef880cfdf24b11ddea4bb382e">+637/-0</a> </td> </tr> <tr> <td><strong>4-graphql-operations.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ad7a84d0c924b946d992f5277cde596fb9d42eff021b888b5a8046822afabca8">+828/-0</a> </td> </tr> <tr> <td><strong>5-file-uploads.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-00c26651dea4650fd77222a24b31387569067783a87d9c6715935e2bf133428e">+720/-0</a> </td> </tr> <tr> <td><strong>auth_user_security_keys.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-385036f3c18b6efd97138842e95f992a0569f612f627e71091c10952d8d31609">+2/-11</a> </td> </tr> <tr> <td><strong>auth_users.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-aba47a0c058f0a0a8122dee4d7b4394c4a8997bac9d4ccea04c41f0d00819050">+0/-24</a> </td> </tr> <tr> <td><strong>storage_files.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ea0619f75a1f7dd13ca80d81cc2e28529594a73fff0356dc0b1d49ee0d1cc9ad">+17/-36</a> </td> </tr> <tr> <td><strong>tables.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a57c35142b6c4029fad7d5c8407305a61d18078cd0e264d41286625cf5cc4d30">+1/-5</a> </td> </tr> <tr> <td><strong>down.sql</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-d7be59389936d96f1c3b10f2147b177d488906d2155139536fae635201859c3e">+1/-0</a> </td> </tr> <tr> <td><strong>up.sql</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-69c6c175ba9819f7b41d6a27e78fd0390e222665c9cc95fbb7fde702f14a1f4c">+26/-0</a> </td> </tr> <tr> <td><strong>nhost.toml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-45587ca86b6441ceacd1318e72a60ee0c30c70c00edb0a06cf061e404998f3bc">+4/-4</a> </td> </tr> <tr> <td><strong>actions.ts</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a0279ec5849eb0c93ec7c2f444fbecd2b681f47d954a84da5221f72d9805c2ca">+12/-0</a> </td> </tr> <tr> <td><strong>page.tsx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8fffbc65277c93c49eea289ae1174cdba0632e5a295b74668437e9b1b0669097">+61/-30</a> </td> </tr> <tr> <td><strong>middleware.ts</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-817666e0f29fa78d2d7f77ed93cff4efbc8d85a996ab58d0dd13ad61e47c9125">+5/-3</a> </td> </tr> <tr> <td><strong>App.tsx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-849f3aa52970f348de49a27094aac4e4b8cb8cf29580cada70d37f1a04249725">+2/-0</a> </td> </tr> <tr> <td><strong>Navigation.tsx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-9845c755953914963f404a14104d93cd326f78e48614ece8c4d9df3ab1600ffd">+3/-0</a> </td> </tr> <tr> <td><strong>index.css</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8e415aa656412ff29b45f56035e84691f3abcc924bfb5b4e3cd0c9ba5237543b">+200/-0</a> </td> </tr> <tr> <td><strong>+page.svelte</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-00da2a0f738ffa5c66a07437c7e85cacb7780551bad7e81899d905f065bf0892">+29/-8</a> </td> </tr> <tr> <td><strong>main.css</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-1d2ba653024753e96f2db949f818dccba45498456146171c4a2f883b58177d2f">+373/-0</a> </td> </tr> <tr> <td><strong>Navigation.vue</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-27e2f662a54c47023a1342005bcf92602a846ad7a59e9d7d528aa8fbf40a0250">+7/-0</a> </td> </tr> <tr> <td><strong>index.ts</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3bf427bc13a501168d321cf4bd428d09d8c3a042ef3be955c7a2e99bbf5f39dd">+7/-0</a> </td> </tr> <tr> <td><strong>SignUp.vue</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-1838012876d16508d53d0345615b91c7c6fbccca3c9333d2311ac5fcd7b4c210">+28/-3</a> </td> </tr> <tr> <td><strong>Todos.vue</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3334950776983589e7061d69be930b71663329a6ba56726bcbd3f1c2352dab47">+466/-0</a> </td> </tr> <tr> <td><strong>Makefile</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-85a3083c78e211e9eb36d741342bcbc85a1a0c375060f45c5426b560196de27f">+2/-2</a> </td> </tr> <tr> <td><strong>.secrets.example</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-be7a988f18be877344f8584befc094ec2e66fe7784a2007300053707ad8ec7f1">+16/-0</a> </td> </tr> <tr> <td><strong>Makefile</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-36623bab6fe16382fd3e61b06b9586f2b14bea7c1b492e50db14ea98935016a4">+7/-0</a> </td> </tr> <tr> <td><strong>README.md</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ed02e93d01774c3796335c83badc90cbc8a2035040965031946b69c8d91db3dd">+29/-0</a> </td> </tr> <tr> <td><strong>env-up.sh</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-b6846a8b7ff5eea35ba5ff2aac8773f526c118ef59712a750f69578f25afda54">+8/-0</a> </td> </tr> <tr> <td><strong>package.json</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-f0d63d57cf77fdb7f2526d10c2d32d51ed2b6d43e3495d3f8879499e0c04f04b">+13/-0</a> </td> </tr> <tr> <td><strong>tsconfig.json</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-cad0e83f030efa9fcad494c7797838c5099909f2c2ae0de0210812b966ffa6db">+11/-0</a> </td> </tr> <tr> <td><strong>config.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-997042f08b088725275a28a7e2d275b86e2e74f3c972800e8116785d5d0fea59">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3c921023f82fc74f5b657cb92bc8906ea19d7eb8f746713e4fae2a5a894a8a59">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5f67fb54ebc23bfccd1e23fbb7dc955bad8ad9011fae19369c87bf3f4d3e58fa">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3c21997bc2db49d5714afc007f214316d17eb69d8ea70dd1cfe558bdb47df4bf">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-e3ab8737d4a9ea68a115f28ce578b7502107f68212d92f035bb86178ccdfb6fe">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ed34cbd1c3e612ac3124ccce382b84ea7cda7eb22051b07102b68f5c28c58e97">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-b8cead9967aaddc7c52be376fcd54b27c6ec223445e0063729c7255ab6c4f5fc">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-295cddc1774338f5b6ba396ef5ca9a0832b9b2346703ec22abf3b3874f903cc2">+43/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-de3e6c264684c0dddb2f6e70dcb236fe8cb1a27789c01a11b2a707c110ce7fc2">+1/-0</a> </td> </tr> <tr> <td><strong>body.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ae93f5f97d52d1d42be91b50e2a44522aef884065559f993f781f1ebeaa89b66">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-13511e1d49a2796e2ee0bb2082f02ed59c49bef7f7533735e60ac62ad16cc2c6">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-293b460447946e2de5e4ba74cf64895a4f6fff7d879bb2e555c46ad7edd857a8">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-843300abe10319ac2b6ccffdd8daf3445b8eaa839486acb95566d398cad4e053">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8ff22c43814b945c0a22ea9089755a144e54039bdd12a2f06a128b4b155b3e38">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-f9e07ca4b5b9ae54e1668c3dfb8ac32612df366eb7d44819163f17c792507295">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-4f829f73c5bce9bcbb67ed1357a111cdf9974c36d2db22493067d201b35f4cdf">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-64f6273ffcec137bbad9a104cd46e6a8776915a804bb3f89c24701071cbb2c50">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-e19bb81615465996e46da50eacac7fddf71dd8bb18200c410b1837575bd76f95">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a202edc97b202ec41b83decb7b358a6beb89f3edef46194b576ecaada51f073d">+43/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-c2338d16f093d53b8a86b9a859c7bef0d9ef2c83a762d46cd49ab9e2f1f2a773">+1/-0</a> </td> </tr> <tr> <td><strong>body.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-7fff5a89330dd91d3c9781e37d8f1e1f48bfc58c686b463b7fb1f43a2d01813d">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-c9031a1f7ac5ad3d0c3c94cff2f563560c1e3c05d1acc00353e603f3dcac439e">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-847d33f3faf4a7f928445eb389ba8aa24901b9025d4bae0c38becd5e9365a881">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3f5c827ff0c5a3270dc4c7737b0afd09327800104cb379ed2a556029eb58aef3">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-e499fcf53b46507a64be23ae309eba07e2c3ee289be4de316bd8ee83c16ea42e">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5a6337adab234155f13e6b7d63eac5c654d5c75f4ea207c9db16b67685ce3e43">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-b8f9c01ea13429ca9990a95e81ac6bedb273ec41198f34903584e9a68c22c6ef">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5bfe4c29aee11546871f9127b5c5a954f313c64d2344cd4e6cd402fe22cf0ded">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-262bf98a3f4801aff364e19a4c00bf37197764454b274faf1dbd947506b843a7">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8a4ad52e5c3cd679ede51f7d411ca5aaf89f2f519f49c540e09408bf13823323">+43/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-46f8a60e052ff7b9b3d8583f83f36b826aea9c7a24af9e9998cc0b699900b3b1">+1/-0</a> </td> </tr> <tr> <td><strong>body.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-03da104febe035cf096fccbce0dbbf32197d289de814810792a319154241200c">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-d7fff652e5e21d234b1ab733de23d179d5901ff82165ffbd9495b3a1ab1c7611">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a84317c8ffe3b56140c923228666555d0e9e93c34eb5a8376b08ab97e6e4ae96">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5025ef2a43d19c4f668177eea4e8002664a22522e11fef04aabbcdb2fbff3178">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-e941c399a4d0b15de5bf2b20e722c2e6ffc990ecdef15efc2367ae302a52ff63">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-126da45f26e2d9256a28fadd38f363277926de563e6b303027cbea24779c729c">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-4686c2e7f183e071c8ccd456189d9bd056ead4874078dba16b1a2b1e53c7e9e4">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-331385785301bc1e55cfb6a3c707e3704b32d82a38f7ccd9aa4aa834a8569df4">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-57d1b1020e0d2e327acca4154b0e4776f3a7a5db1d6c99edae3ee45561780c6a">+1/-0</a> </td> </tr> <tr> <td><strong>Additional files not shown</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-2f328e4cd8dbe3ad193e49d92bcf045f47a6b72b1e9487d366f6b8288589b4ca"></a></td> </tr> </table></details></td></tr></tr></tbody></table> </details> ___
716 lines
21 KiB
Plaintext
716 lines
21 KiB
Plaintext
---
|
|
title: File Uploads in React
|
|
description: Learn how to implement file upload functionality with storage buckets and permissions while building a complete file management system with Nhost and React
|
|
sidebarTitle: "File Uploads"
|
|
icon: upload
|
|
---
|
|
|
|
This part builds upon the previous GraphQL operations part by demonstrating how to implement file upload functionality with proper storage permissions. You'll learn how to create storage buckets, configure upload permissions, and implement complete file management operations in a React application.
|
|
|
|
<Info>
|
|
This is **Part 5** in the Full-Stack React Development with Nhost series. This part focuses on file storage, upload operations, and permission-based file access control in a production application.
|
|
</Info>
|
|
|
|
## Full-Stack React Development with Nhost
|
|
|
|
<CardGroup cols={3}>
|
|
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/react/1-introduction">
|
|
Set up your Nhost project
|
|
</Card>
|
|
|
|
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/react/2-protected-routes">
|
|
Route protection basics
|
|
</Card>
|
|
|
|
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/react/3-user-authentication">
|
|
Complete auth flow
|
|
</Card>
|
|
|
|
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/react/4-graphql-operations">
|
|
CRUD operations with GraphQL
|
|
</Card>
|
|
|
|
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/react/5-file-uploads">
|
|
**Current** - File upload and management
|
|
</Card>
|
|
</CardGroup>
|
|
|
|
## Prerequisites
|
|
|
|
- Complete the [GraphQL Operations part](/getting-started/tutorials/react/4-graphql-operations) first
|
|
- The project from the previous part set up and running
|
|
|
|
## What You'll Build
|
|
|
|
By the end of this part, you'll have:
|
|
- A **personal bucket** so users can upload their own private files
|
|
- **File upload functionality**
|
|
- **File management interface** for viewing and deleting files
|
|
- **Security permissions** ensuring users can only access their own files
|
|
|
|
## Step-by-Step Guide
|
|
|
|
<Steps>
|
|
<Step>
|
|
|
|
### Create a Personal Storage Bucket
|
|
|
|
First, we'll create a storage bucket where users can upload their personal files.
|
|
|
|
In your Nhost project dashboard:
|
|
1. Navigate to **Database**
|
|
2. Change to **schema.storage**, then buckets
|
|
3. Now click on `+ Insert` on the top right corner.
|
|
4. As id set `personal`, leave the rest of the fields blank and click on Insert at the bottom
|
|
|
|

|
|
|
|
</Step>
|
|
|
|
<Step>
|
|
|
|
### Configure Storage Permissions
|
|
|
|
Now we need to set up permissions for the storage bucket to ensure the `user` role can only upload, view, and delete their own files.
|
|
|
|
<Tabs>
|
|
|
|
<Tab title="Upload">
|
|
|
|
To upload files we need to grant permissions to insert on the table `storage.files`. Because we want to allow uploading files only to the `personal` bucket we will be using the `bucket_id eq personal` as a custom check. In addition, we are configuring a preset `uploaded_by_user_id = X-Hasura-User-id`, this will automatically extract the user_id from the session and set the column accordingly. Then we can use this in other permissions to allow downloading files and deleting them.
|
|
|
|

|
|
|
|
</Tab>
|
|
|
|
<Tab title="Download">
|
|
|
|
To download files users need to be able to query those files. To make sure users can only download files they uploaded we will be leveraging the column `uploaded_by_user_id` column from before and the `bucket_id``.
|
|
|
|

|
|
|
|
</Tab>
|
|
|
|
<Tab title="Delete">
|
|
|
|
Similarly to downloading files, to delete files users need to be able to delete rows from the `storage.files` table. Again we will use the `uploaded_by_user_id` column and the `bucket_id` to make sure users can only delete their own files.
|
|
|
|

|
|
|
|
</Tab>
|
|
|
|
</Tabs>
|
|
|
|
<Info>
|
|
You can read more about storage permissions [here](/products/storage/overview#permissions)
|
|
</Info>
|
|
|
|
</Step>
|
|
|
|
<Step>
|
|
|
|
### Create the File Upload Component
|
|
|
|
Now let's implement the React component for file upload functionality.
|
|
|
|
```tsx src/pages/Files.tsx lines
|
|
import type { FileMetadata } from "@nhost/nhost-js/storage";
|
|
import { type JSX, useCallback, useEffect, useRef, useState } from "react";
|
|
import { useAuth } from "../lib/nhost/AuthProvider";
|
|
|
|
interface DeleteStatus {
|
|
message: string;
|
|
isError: boolean;
|
|
}
|
|
|
|
interface GraphqlGetFilesResponse {
|
|
files: FileMetadata[];
|
|
}
|
|
|
|
function formatFileSize(bytes: number): string {
|
|
if (bytes === 0) return "0 Bytes";
|
|
|
|
const sizes: string[] = ["Bytes", "KB", "MB", "GB", "TB"];
|
|
const i: number = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
|
|
return `${parseFloat((bytes / 1024 ** i).toFixed(2))} ${sizes[i]}`;
|
|
}
|
|
|
|
export default function Files(): JSX.Element {
|
|
const { isAuthenticated, nhost } = useAuth();
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
const [uploading, setUploading] = useState<boolean>(false);
|
|
const [uploadResult, setUploadResult] = useState<FileMetadata | null>(null);
|
|
const [isFetching, setIsFetching] = useState<boolean>(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [files, setFiles] = useState<FileMetadata[]>([]);
|
|
const [viewingFile, setViewingFile] = useState<string | null>(null);
|
|
const [deleting, setDeleting] = useState<string | null>(null);
|
|
const [deleteStatus, setDeleteStatus] = useState<DeleteStatus | null>(null);
|
|
|
|
const fetchFiles = useCallback(async (): Promise<void> => {
|
|
setIsFetching(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Use GraphQL to fetch files from the storage system
|
|
// Files are automatically filtered by user permissions
|
|
const response = await nhost.graphql.request<GraphqlGetFilesResponse>({
|
|
query: `query GetFiles {
|
|
files {
|
|
id
|
|
name
|
|
size
|
|
mimeType
|
|
bucketId
|
|
uploadedByUserId
|
|
}
|
|
}`,
|
|
});
|
|
|
|
if (response.body.errors) {
|
|
throw new Error(
|
|
response.body.errors[0]?.message || "Failed to fetch files",
|
|
);
|
|
}
|
|
|
|
setFiles(response.body.data?.files || []);
|
|
} catch (err) {
|
|
console.error("Error fetching files:", err);
|
|
setError("Failed to load files. Please try refreshing the page.");
|
|
} finally {
|
|
setIsFetching(false);
|
|
}
|
|
}, [nhost.graphql]);
|
|
|
|
useEffect(() => {
|
|
if (isAuthenticated) {
|
|
fetchFiles();
|
|
}
|
|
}, [isAuthenticated, fetchFiles]);
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
|
if (e.target.files && e.target.files.length > 0) {
|
|
const file = e.target.files[0];
|
|
if (file) {
|
|
setSelectedFile(file);
|
|
setError(null);
|
|
setUploadResult(null);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleUpload = async (): Promise<void> => {
|
|
if (!selectedFile) {
|
|
setError("Please select a file to upload");
|
|
return;
|
|
}
|
|
|
|
setUploading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Upload file to the personal bucket
|
|
// The uploadedByUserId is automatically set by the storage permissions
|
|
const response = await nhost.storage.uploadFiles({
|
|
"bucket-id": "personal",
|
|
"file[]": [selectedFile],
|
|
});
|
|
|
|
const uploadedFile = response.body.processedFiles?.[0];
|
|
if (uploadedFile === undefined) {
|
|
throw new Error("Failed to upload file");
|
|
}
|
|
setUploadResult(uploadedFile);
|
|
|
|
// Clear the form
|
|
setSelectedFile(null);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = "";
|
|
}
|
|
|
|
// Update the files list
|
|
setFiles((prevFiles) => [uploadedFile, ...prevFiles]);
|
|
|
|
await fetchFiles();
|
|
|
|
// Clear success message after 3 seconds
|
|
setTimeout(() => {
|
|
setUploadResult(null);
|
|
}, 3000);
|
|
} catch (err: unknown) {
|
|
const message = (err as Error).message || "An unknown error occurred";
|
|
setError(`Failed to upload file: ${message}`);
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
const handleViewFile = async (
|
|
fileId: string,
|
|
fileName: string,
|
|
mimeType: string,
|
|
): Promise<void> => {
|
|
setViewingFile(fileId);
|
|
|
|
try {
|
|
// Get the file from storage
|
|
const response = await nhost.storage.getFile(fileId);
|
|
|
|
const url = URL.createObjectURL(response.body);
|
|
|
|
// Handle different file types appropriately
|
|
if (
|
|
mimeType.startsWith("image/") ||
|
|
mimeType === "application/pdf" ||
|
|
mimeType.startsWith("text/") ||
|
|
mimeType.startsWith("video/") ||
|
|
mimeType.startsWith("audio/")
|
|
) {
|
|
// Open viewable files in new tab
|
|
window.open(url, "_blank");
|
|
} else {
|
|
// Download other file types
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = fileName;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
|
|
// Show download confirmation
|
|
const newWindow = window.open("", "_blank", "width=400,height=200");
|
|
if (newWindow) {
|
|
newWindow.document.documentElement.innerHTML = `
|
|
<head>
|
|
<title>File Download</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; padding: 20px; text-align: center; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h3>Downloading: ${fileName}</h3>
|
|
<p>Your download has started. You can close this window.</p>
|
|
</body>
|
|
`;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
const message = (err as Error).message || "An unknown error occurred";
|
|
setError(`Failed to view file: ${message}`);
|
|
console.error("Error viewing file:", err);
|
|
} finally {
|
|
setViewingFile(null);
|
|
}
|
|
};
|
|
|
|
const handleDeleteFile = async (fileId: string): Promise<void> => {
|
|
if (!fileId || deleting) return;
|
|
|
|
setDeleting(fileId);
|
|
setError(null);
|
|
setDeleteStatus(null);
|
|
|
|
const fileToDelete = files.find((file) => file.id === fileId);
|
|
const fileName = fileToDelete?.name || "File";
|
|
|
|
try {
|
|
// Delete file from storage
|
|
// Permissions ensure users can only delete their own files
|
|
await nhost.storage.deleteFile(fileId);
|
|
|
|
setDeleteStatus({
|
|
message: `${fileName} deleted successfully`,
|
|
isError: false,
|
|
});
|
|
|
|
// Remove from local state
|
|
setFiles(files.filter((file) => file.id !== fileId));
|
|
|
|
await fetchFiles();
|
|
|
|
// Clear success message after 3 seconds
|
|
setTimeout(() => {
|
|
setDeleteStatus(null);
|
|
}, 3000);
|
|
} catch (err) {
|
|
const message = (err as Error).message || "An unknown error occurred";
|
|
setDeleteStatus({
|
|
message: `Failed to delete ${fileName}: ${message}`,
|
|
isError: true,
|
|
});
|
|
console.error("Error deleting file:", err);
|
|
} finally {
|
|
setDeleting(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="container">
|
|
<header className="page-header">
|
|
<h1 className="page-title">File Upload</h1>
|
|
</header>
|
|
|
|
<div className="form-card">
|
|
<h2 className="form-title">Upload a File</h2>
|
|
|
|
<div className="field-group">
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
onChange={handleFileChange}
|
|
style={{
|
|
position: "absolute",
|
|
width: "1px",
|
|
height: "1px",
|
|
padding: 0,
|
|
margin: "-1px",
|
|
overflow: "hidden",
|
|
clip: "rect(0,0,0,0)",
|
|
border: 0,
|
|
}}
|
|
aria-hidden="true"
|
|
tabIndex={-1}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary file-upload-btn"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<svg
|
|
width="40"
|
|
height="40"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
role="img"
|
|
aria-label="Upload file"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
|
/>
|
|
</svg>
|
|
<p>Click to select a file</p>
|
|
{selectedFile && (
|
|
<p className="file-upload-info">
|
|
{selectedFile.name} ({formatFileSize(selectedFile.size)})
|
|
</p>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{error && <div className="error-message">{error}</div>}
|
|
|
|
{uploadResult && (
|
|
<div className="success-message">File uploaded successfully!</div>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleUpload}
|
|
disabled={!selectedFile || uploading}
|
|
className="btn btn-primary"
|
|
style={{ width: "100%" }}
|
|
>
|
|
{uploading ? "Uploading..." : "Upload File"}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="form-card">
|
|
<h2 className="form-title">Your Files</h2>
|
|
|
|
{deleteStatus && (
|
|
<div
|
|
className={
|
|
deleteStatus.isError ? "error-message" : "success-message"
|
|
}
|
|
>
|
|
{deleteStatus.message}
|
|
</div>
|
|
)}
|
|
|
|
{isFetching ? (
|
|
<div className="loading-container">
|
|
<div className="loading-content">
|
|
<div className="spinner"></div>
|
|
<span className="loading-text">Loading files...</span>
|
|
</div>
|
|
</div>
|
|
) : files.length === 0 ? (
|
|
<div className="empty-state">
|
|
<svg
|
|
className="empty-icon"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.5}
|
|
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
|
/>
|
|
</svg>
|
|
<h3 className="empty-title">No files yet</h3>
|
|
<p className="empty-description">
|
|
Upload your first file to get started!
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div style={{ overflowX: "auto" }}>
|
|
<table className="file-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Type</th>
|
|
<th>Size</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{files.map((file) => (
|
|
<tr key={file.id}>
|
|
<td className="file-name">{file.name}</td>
|
|
<td className="file-meta">{file.mimeType}</td>
|
|
<td className="file-meta">
|
|
{formatFileSize(file.size || 0)}
|
|
</td>
|
|
<td>
|
|
<div className="file-actions">
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
handleViewFile(
|
|
file.id || "unknown",
|
|
file.name || "unknown",
|
|
file.mimeType || "unknown",
|
|
)
|
|
}
|
|
disabled={viewingFile === file.id}
|
|
className="action-btn action-btn-edit"
|
|
title="View File"
|
|
>
|
|
{viewingFile === file.id ? "⏳" : "👁️"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDeleteFile(file.id || "unknown")}
|
|
disabled={deleting === file.id}
|
|
className="action-btn action-btn-delete"
|
|
title="Delete File"
|
|
>
|
|
{deleting === file.id ? "⏳" : "🗑️"}
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
</Step>
|
|
|
|
<Step>
|
|
|
|
### Update Application Routes
|
|
|
|
Add the uploads page to your application routing.
|
|
|
|
```tsx src/App.tsx lines highlight={18,43}
|
|
import {
|
|
createBrowserRouter,
|
|
createRoutesFromElements,
|
|
Navigate,
|
|
Outlet,
|
|
Route,
|
|
RouterProvider,
|
|
} from "react-router-dom";
|
|
import Navigation from "./components/Navigation";
|
|
import ProtectedRoute from "./components/ProtectedRoute";
|
|
import { AuthProvider } from "./lib/nhost/AuthProvider";
|
|
import Files from "./pages/Files";
|
|
import Home from "./pages/Home";
|
|
import Profile from "./pages/Profile";
|
|
import SignIn from "./pages/SignIn";
|
|
import SignUp from "./pages/SignUp";
|
|
import Todos from "./pages/Todos";
|
|
import Verify from "./pages/Verify";
|
|
|
|
// Root layout component to wrap all routes
|
|
const RootLayout = () => {
|
|
return (
|
|
<>
|
|
<Navigation />
|
|
<div className="app-content">
|
|
<Outlet />
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
// Create router with routes
|
|
const router = createBrowserRouter(
|
|
createRoutesFromElements(
|
|
<Route element={<RootLayout />}>
|
|
<Route index element={<Home />} />
|
|
<Route path="signin" element={<SignIn />} />
|
|
<Route path="signup" element={<SignUp />} />
|
|
<Route path="verify" element={<Verify />} />
|
|
<Route element={<ProtectedRoute />}>
|
|
<Route path="profile" element={<Profile />} />
|
|
<Route path="todos" element={<Todos />} />
|
|
<Route path="files" element={<Files />} />
|
|
</Route>
|
|
<Route path="*" element={<Navigate to="/" />} />
|
|
</Route>,
|
|
),
|
|
);
|
|
|
|
function App() {
|
|
return (
|
|
<AuthProvider>
|
|
<RouterProvider router={router} />
|
|
</AuthProvider>
|
|
);
|
|
}
|
|
|
|
export default App;
|
|
```
|
|
|
|
</Step>
|
|
|
|
<Step>
|
|
|
|
### Update Navigation Links
|
|
|
|
Add a link to the uploads page in the navigation bar.
|
|
|
|
```tsx src/components/Navigation.tsx lines highlight={38-40}
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import { useAuth } from "../lib/nhost/AuthProvider";
|
|
|
|
export default function Navigation() {
|
|
const { isAuthenticated, session, nhost } = useAuth();
|
|
const navigate = useNavigate();
|
|
|
|
const handleSignOut = async () => {
|
|
try {
|
|
if (session) {
|
|
await nhost.auth.signOut({
|
|
refreshToken: session.refreshToken,
|
|
});
|
|
}
|
|
navigate("/");
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.error("Error signing out:", message);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<nav className="navigation">
|
|
<div className="nav-container">
|
|
<Link to="/" className="nav-logo">
|
|
Nhost React Demo
|
|
</Link>
|
|
|
|
<div className="nav-links">
|
|
<Link to="/" className="nav-link">
|
|
Home
|
|
</Link>
|
|
|
|
{isAuthenticated ? (
|
|
<>
|
|
<Link to="/todos" className="nav-link">
|
|
Todos
|
|
</Link>
|
|
<Link to="/files" className="nav-link">
|
|
Files
|
|
</Link>
|
|
<Link to="/profile" className="nav-link">
|
|
Profile
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={handleSignOut}
|
|
className="nav-link nav-button"
|
|
>
|
|
Sign Out
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Link to="/signin" className="nav-link">
|
|
Sign In
|
|
</Link>
|
|
<Link to="/signup" className="nav-link">
|
|
Sign Up
|
|
</Link>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
```
|
|
|
|
</Step>
|
|
|
|
<Step>
|
|
|
|
### Test Your File Upload System
|
|
|
|
Run your application and test all the functionality:
|
|
|
|
```bash
|
|
npm run dev
|
|
```
|
|
|
|
Things to try out:
|
|
|
|
1. Try signing in and out and see how the file upload page is only accessible when signed in.
|
|
2. Upload different types of files (images, documents, etc.)
|
|
3. View and delete files
|
|
4. Sign in with another account and verify you cannot see files from the first account
|
|
|
|
</Step>
|
|
</Steps>
|
|
|
|
## Key Features Implemented
|
|
|
|
<AccordionGroup>
|
|
<Accordion title="Storage Bucket" icon="bucket">
|
|
Dedicated personal storage bucket with proper configuration for user file isolation.
|
|
</Accordion>
|
|
|
|
<Accordion title="File Upload Interface" icon="upload">
|
|
User-friendly upload interface with file selection, preview, and progress feedback.
|
|
</Accordion>
|
|
|
|
<Accordion title="File Management" icon="folder">
|
|
Complete file listing with metadata, viewing capabilities, and deletion functionality.
|
|
</Accordion>
|
|
|
|
<Accordion title="File Type Handling" icon="file">
|
|
Intelligent handling of different file types with appropriate viewing/download behavior.
|
|
</Accordion>
|
|
|
|
<Accordion title="Error Handling" icon="triangle-exclamation">
|
|
Comprehensive error handling with user-friendly messages for upload and management operations.
|
|
</Accordion>
|
|
</AccordionGroup>
|