From 5cac1f373047fcdda16a7cfbad3526c8aec8ddf3 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Fri, 17 Jan 2025 17:50:07 -0800 Subject: [PATCH 1/2] =?UTF-8?q?=E0=BC=BC=20=E3=81=A4=20=E2=97=95=5F?= =?UTF-8?q?=E2=97=95=20=E0=BC=BD=E3=81=A4=20Give=20Storage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/packages/admin/src/index.ts | 22 + client/packages/core/src/Reactor.js | 46 +- client/packages/core/src/StorageAPI.ts | 72 +- client/packages/core/src/index.ts | 12 +- .../admin-sdk-express/src/circle_blue.jpg | Bin 0 -> 2576 bytes client/sandbox/admin-sdk-express/src/index.ts | 116 ++- .../sandbox/react-nextjs/imgs/circle_blue.jpg | Bin 0 -> 2576 bytes .../react-nextjs/imgs/circle_green.jpg | Bin 0 -> 2383 bytes .../sandbox/react-nextjs/imgs/circle_red.jpg | Bin 0 -> 2543 bytes .../sandbox/react-nextjs/imgs/square_blue.jpg | Bin 0 -> 1988 bytes .../react-nextjs/imgs/square_green.jpg | Bin 0 -> 1936 bytes .../sandbox/react-nextjs/imgs/square_red.jpg | Bin 0 -> 2027 bytes .../react-nextjs/imgs/triangle:green copy.jpg | Bin 0 -> 2242 bytes .../react-nextjs/imgs/triangle:red copy.jpg | Bin 0 -> 2377 bytes .../react-nextjs/imgs/triangle_blue.jpg | Bin 0 -> 2404 bytes .../react-nextjs/imgs/triangle_green.jpg | Bin 0 -> 2242 bytes .../react-nextjs/imgs/triangle_red.jpg | Bin 0 -> 2377 bytes .../react-nextjs/pages/play/storage-v2.tsx | 224 ++++++ client/www/components/dash/Billing.tsx | 12 + client/www/components/dash/Storage.tsx | 726 +----------------- .../www/components/dash/explorer/Explorer.tsx | 274 +++++-- client/www/lib/schema.tsx | 10 +- .../45_add_storage_metrics.down.sql | 1 + .../migrations/45_add_storage_metrics.up.sql | 13 + server/src/instant/admin/routes.clj | 39 +- server/src/instant/dash/admin.clj | 38 +- server/src/instant/dash/routes.clj | 77 +- server/src/instant/db/instaql.clj | 61 +- server/src/instant/db/model/attr.clj | 3 +- .../instant/db/permissioned_transaction.clj | 5 +- server/src/instant/db/transaction.clj | 42 +- server/src/instant/model/app_file.clj | 107 +++ server/src/instant/model/app_user.clj | 14 +- server/src/instant/model/rule.clj | 27 +- server/src/instant/runtime/routes.clj | 2 +- server/src/instant/storage/coordinator.clj | 86 +++ server/src/instant/storage/routes.clj | 101 ++- server/src/instant/storage/s3.clj | 336 +++++--- server/src/instant/system_catalog.clj | 32 +- server/src/instant/system_catalog_ops.clj | 7 +- server/src/instant/util/s3.clj | 155 ++++ 41 files changed, 1617 insertions(+), 1043 deletions(-) create mode 100644 client/sandbox/admin-sdk-express/src/circle_blue.jpg create mode 100644 client/sandbox/react-nextjs/imgs/circle_blue.jpg create mode 100644 client/sandbox/react-nextjs/imgs/circle_green.jpg create mode 100644 client/sandbox/react-nextjs/imgs/circle_red.jpg create mode 100644 client/sandbox/react-nextjs/imgs/square_blue.jpg create mode 100644 client/sandbox/react-nextjs/imgs/square_green.jpg create mode 100644 client/sandbox/react-nextjs/imgs/square_red.jpg create mode 100644 client/sandbox/react-nextjs/imgs/triangle:green copy.jpg create mode 100644 client/sandbox/react-nextjs/imgs/triangle:red copy.jpg create mode 100644 client/sandbox/react-nextjs/imgs/triangle_blue.jpg create mode 100644 client/sandbox/react-nextjs/imgs/triangle_green.jpg create mode 100644 client/sandbox/react-nextjs/imgs/triangle_red.jpg create mode 100644 client/sandbox/react-nextjs/pages/play/storage-v2.tsx create mode 100644 server/resources/migrations/45_add_storage_metrics.down.sql create mode 100644 server/resources/migrations/45_add_storage_metrics.up.sql create mode 100644 server/src/instant/model/app_file.clj create mode 100644 server/src/instant/storage/coordinator.clj create mode 100644 server/src/instant/util/s3.clj diff --git a/client/packages/admin/src/index.ts b/client/packages/admin/src/index.ts index dccc33a72..5ba79b505 100644 --- a/client/packages/admin/src/index.ts +++ b/client/packages/admin/src/index.ts @@ -646,6 +646,28 @@ class Storage { return ok; }; + upload2 = async ( + filename: string, + file: Buffer, + metadata: UploadMetadata = {}, + ): Promise => { + const formData = new FormData(); + formData.append('path', filename); + formData.append('file', new Blob([file])); + + const headers = authorizedHeaders(this.config) + delete headers["content-type"]; + + const data = await jsonFetch(`${this.config.apiURI}/admin/storage/upload`, { + method: "POST", + headers, + body: formData + }) + + return data; + }; + + /** * Retrieves a download URL for the provided path. * diff --git a/client/packages/core/src/Reactor.js b/client/packages/core/src/Reactor.js index 9d0a676aa..b2b056b49 100644 --- a/client/packages/core/src/Reactor.js +++ b/client/packages/core/src/Reactor.js @@ -432,13 +432,13 @@ export default class Reactor { break; } - // Now that this transaction is accepted, - // We can delete it from our queue. + // Now that this transaction is accepted, + // We can delete it from our queue. this.pendingMutations.set((prev) => { prev.delete(eventId); return prev; }); - + // We apply this transaction to all our existing queries const txStepsToApply = prevMutation["tx-steps"]; this.querySubs.set((prev) => { @@ -457,9 +457,9 @@ export default class Reactor { .filter(([action, ..._args]) => action === "add-attr") .map(([_action, attr]) => attr) .concat(Object.values(this.attrs)); - + this._setAttrs(newAttrs); - + this._finishTransaction("synced", eventId); break; case "patch-presence": { @@ -1494,7 +1494,7 @@ export default class Reactor { appId: this.config.appId, refreshToken, }); - } catch (e) {} + } catch (e) { } } await this.changeCurrentUser(null); } @@ -1787,18 +1787,44 @@ export default class Reactor { async upload(path, file) { const currentUser = await this.getCurrentUser(); const refreshToken = currentUser?.user?.refresh_token; - const fileName = path || file.name; - const url = await StorageApi.getSignedUploadUrl({ + const fileName = (path || file.name).replace(':', '/'); + const { url, file_id } = await StorageApi.getSignedUploadUrl({ apiURI: this.config.apiURI, appId: this.config.appId, - fileName: fileName, + fileName, refreshToken: refreshToken, }); const isSuccess = await StorageApi.upload(url, file); + await StorageApi.markUploadReady({ + apiURI: this.config.apiURI, + appId: this.config.appId, + fileName, + refreshToken: refreshToken, + }); + return isSuccess; } + async upload2(path, file) { + const currentUser = await this.getCurrentUser(); + const refreshToken = currentUser?.user?.refresh_token; + // (XXX): Remove replace code, this is temporary for testing s3 paths + const cleanPath = path.replace(':', '/'); + return StorageApi.upload2({ + apiURI: this.config.apiURI, + appId: this.config.appId, + path: cleanPath, + file, + refreshToken: refreshToken, + }); + } + + + /** + * @deprecated Use loadUrl() instead. getDownloadUrl() will be removed in the + * future. + */ async getDownloadUrl(path) { const currentUser = await this.getCurrentUser(); const refreshToken = currentUser?.user?.refresh_token; @@ -1818,7 +1844,7 @@ export default class Reactor { const result = await StorageApi.deleteFile({ apiURI: this.config.apiURI, appId: this.config.appId, - path: path, + path, refreshToken: refreshToken, }); diff --git a/client/packages/core/src/StorageAPI.ts b/client/packages/core/src/StorageAPI.ts index 102085d99..5e62a7d93 100644 --- a/client/packages/core/src/StorageAPI.ts +++ b/client/packages/core/src/StorageAPI.ts @@ -13,7 +13,7 @@ export async function getSignedUploadUrl({ refreshToken?: string; metadata?: Record; }) { - const { data } = await jsonFetch(`${apiURI}/storage/signed-upload-url`, { + const data = await jsonFetch(`${apiURI}/storage/signed-upload-url`, { method: "POST", headers: { "content-type": "application/json", @@ -40,6 +40,62 @@ export async function upload(presignedUrl, file) { return response.ok; } +export async function upload2({ + apiURI, + appId, + path, + file, + refreshToken, +}: { + apiURI: string; + appId: string; + path: string; + file: File; + refreshToken?: string; +}) { + const formData = new FormData(); + formData.append('app_id', appId); + formData.append('path', path); + formData.append('file', file); + + const data = await jsonFetch(`${apiURI}/storage/upload`, { + method: "POST", + headers: { + authorization: `Bearer ${refreshToken}`, + }, + body: formData, + }); + + return data; +} + + +export async function markUploadReady({ + apiURI, + appId, + fileName, + refreshToken, +}: { + apiURI: string; + appId: string; + fileName: string; + refreshToken?: string; +}) { + const { data } = await jsonFetch(`${apiURI}/storage/mark-upload-ready`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${refreshToken}`, + }, + body: JSON.stringify({ + app_id: appId, + filename: fileName, + }), + }); + + return data; +} + export async function getDownloadUrl({ apiURI, appId, @@ -65,6 +121,20 @@ export async function getDownloadUrl({ return data; } +export function loadUrl({ + apiURI, + appId, + filename, + refreshToken, +}: { + apiURI: string; + appId: string; + filename: string; + refreshToken?: string; +}) { + return `${apiURI}/storage/load-url?app_id=${appId}&filename=${encodeURIComponent(filename)}&refresh_token=${refreshToken}`; +} + export async function deleteFile({ apiURI, appId, diff --git a/client/packages/core/src/index.ts b/client/packages/core/src/index.ts index 571e43ded..eef14952b 100644 --- a/client/packages/core/src/index.ts +++ b/client/packages/core/src/index.ts @@ -332,17 +332,18 @@ class Storage { return this.db.upload(pathname, file); }; + upload2 = (pathname: string, file: File) => { + return this.db.upload2(pathname, file); + }; + + /** * @deprecated Use `db.storage.upload` instead */ put = this.upload; /** - * Retrieves a download URL for the provided path. - * - * @see https://instantdb.com/docs/storage - * @example - * const url = await db.storage.getDownloadUrl('photos/demo.png'); + * @deprecated Use `db.storage.loadUrl` instead */ getDownloadUrl = (pathname: string) => { return this.db.getDownloadUrl(pathname); @@ -358,6 +359,7 @@ class Storage { delete = (pathname: string) => { return this.db.deleteFile(pathname); }; + } // util diff --git a/client/sandbox/admin-sdk-express/src/circle_blue.jpg b/client/sandbox/admin-sdk-express/src/circle_blue.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3d3cc79db3c7fbf3b62d12c6ab56bcbb4238b654 GIT binary patch literal 2576 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 Bqs;&S literal 0 HcmV?d00001 diff --git a/client/sandbox/admin-sdk-express/src/index.ts b/client/sandbox/admin-sdk-express/src/index.ts index 7afa6ed54..b6d09eee2 100644 --- a/client/sandbox/admin-sdk-express/src/index.ts +++ b/client/sandbox/admin-sdk-express/src/index.ts @@ -5,15 +5,23 @@ import { init, tx, id } from "@instantdb/admin"; import { assert } from "console"; import dotenv from "dotenv"; import fs from "fs"; +import path from "path"; dotenv.config(); +// const config = { +// apiURI: "http://localhost:8888", +// appId: process.env.INSTANT_APP_ID!, +// adminToken: process.env.INSTANT_ADMIN_TOKEN!, +// }; + const config = { apiURI: "http://localhost:8888", - appId: process.env.INSTANT_APP_ID!, - adminToken: process.env.INSTANT_ADMIN_TOKEN!, + appId: "831355ee-6a59-4990-8ef3-9c9fe7c26031", + adminToken: "0e91326c-b05a-4c0c-b50a-229ae49f301f", }; + const PERSONAL_ACCESS_TOKEN = process.env.INSTANT_PERSONAL_ACCESS_TOKEN!; const db = init(config); @@ -133,6 +141,17 @@ async function testDeleteUser() { } } +// testCreateToken(); +// testQuery(); +// testTransact(); +// testScoped(); +// testSignOut(); +// testFetchUser(); +// testDeleteUser(); + +/** + * Admin + */ async function testAdminStorage( src: string, dest: string, @@ -167,18 +186,97 @@ async function testAdminStorageBulkDelete(keyword: string) { console.log("After:", await db.storage.list()); } -// testCreateToken(); -// testQuery(); -// testTransact(); -// testScoped(); -// testSignOut(); -// testFetchUser(); -// testDeleteUser(); +async function testUploadFile( + src: string, + dest: string, + contentType?: string, +) { + const buffer = fs.readFileSync(path.join(__dirname, src)); + const data = await db.storage.upload2(dest, buffer, { + contentType: contentType, + }); + console.log("Uploaded:", data); +} + +async function testQueryFiles() { + const res = await query({ $files: {} }); + console.log(JSON.stringify(res, null, 2)); +} + +async function testDeleteSingleFile(filepath: string) { + console.log("Before:", await db.storage.list()); + await db.storage.delete(filepath); + console.log("After:", await db.storage.list()); +} + +async function testDeleteBulkFile(filenames: string[]) { + console.log("Before:", await db.storage.list()); + await db.storage.deleteMany(filenames); + console.log("After:", await db.storage.list()); +} + +async function testUpdateFileFails() { + const fileId = "cbda1941-d192-4f7d-b0a7-f9d428e1ca0b" + const prefix = "Update on $files" + const message = `${prefix} should not be supported` + try { + await transact(tx.$files[fileId].update({ metadata: { "new": "first" } })) + throw new Error(message) + } catch (err) { + if (err instanceof Error && err.message === message) { + throw err + } else { + console.log(`${prefix} failed as expected!`) + } + } +} + +async function testMergeFileFails() { + const fileId = "cbda1941-d192-4f7d-b0a7-f9d428e1ca0b" + const prefix = "Merge on $files" + const message = `${prefix} should not be supported` + try { + await transact(tx.$files[fileId].merge({ metadata: { "new": "second" } })) + throw new Error(message) + } catch (err) { + if (err instanceof Error && err.message === message) { + throw err + } else { + console.log(`${prefix} failed as expected!`) + } + } +} + +async function testDeleteFileTransactFails() { + const prefix = "Delete on $files" + const message = `${prefix} should not be supported` + try { + await transact(tx["$files"][id()].delete()) + throw new Error(message) + } catch (err) { + if (err instanceof Error && err.message === message) { + throw err + } else { + console.log(`${prefix} failed as expected!`) + } + } +} + +// (XXX): Rename these once we validate backwards compatibility // testAdminStorage("src/demo.jpeg", "admin/demo.jpeg", "image/jpeg"); // testAdminStorageFiles(); // testAdminStorageDelete("admin/demo.jpeg"); // testAdminStorageBulkDelete("admin/demo"); +// testUploadFile("circle_blue.jpg", "circle_blue.jpg", "image/jpeg"); +// testUploadFile("circle_blue.jpg", "circle_blue2.jpg", "image/jpeg"); +// testQueryFiles() +// testDeleteSingleFile("circle_blue.jpg"); +// testDeleteBulkFile(["circle_blue.jpg", "circle_blue2.jpg"]); +// testUpdateFileFails() +// testMergeFileFails() +// testDeleteFileTransactFails() + /** * Superadmin */ diff --git a/client/sandbox/react-nextjs/imgs/circle_blue.jpg b/client/sandbox/react-nextjs/imgs/circle_blue.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3d3cc79db3c7fbf3b62d12c6ab56bcbb4238b654 GIT binary patch literal 2576 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 Bqs;&S literal 0 HcmV?d00001 diff --git a/client/sandbox/react-nextjs/imgs/circle_green.jpg b/client/sandbox/react-nextjs/imgs/circle_green.jpg new file mode 100644 index 0000000000000000000000000000000000000000..996af47c6591e910c567fe1f91ae0ba9970e9125 GIT binary patch literal 2383 zcmbW0XH=707KXnx5{kxzfJlo{qzxSb0S!f(T;PIqr3R#3YUl(-5ESHsfOHVW0xCtM z3S7EKQ6mT_ozSZ!A{d&P&+FWowdTjHnR(CJ`_I|utoPaHZ00BCG;rj+o}nH9fdBvm zTmW+n&;bxII2;Z`fC+&>upl{5NKj~Yc2*7y8jHoCF&Iv6{-c~YUM>uV=L8S0fB+tk z=R7JTA|xonFNhcXaR~%Giey0^MxhQ1;xIVD|5?md0D}Tt0aqAA2!LWBFbss*3E)AW z2*{rT{viky1_xcDSlQUYf|?@$6as@mLH{5CSRDoK18@uidqQ4|h11L#DHMQHxR!b! zC9GZ9#%1156H#;tjA3QtKFV{9SM;QqxP+wADPJ-?%U! z7ZiLJIPwP<1R4qk3W!%_bc02g%!Dn2%OhW42r6==Hqx){}1P47m>uKjlX%8JHEf?M33azpdhX06sX zt?|Wcg%ZO|z{Bz8?v3SC>v*@~yySFAdjfY$ewEg08 zN38==3i1{l`~$gguJmYmTOG9y!;uMcOYedb;})x!d+owS1~K=m+SlcwILo)lph%qehcZF0K;v)(&diQC z4riu`z*+iO?S3>G_`IdQOwKbha0s6pz33v8NsMMy1!yduKf9AZ^-5N7X$rls5q!qD zX*DM>)5_pe+Wc$hgP~)5c|1V!!>5HQ&EjNTIx?ju!8e#M!RbS6tk2`u5^}99+WA55 z9m0Bwl6}Fx-76N&w^5kB%h;j_JTsE zVVCU0J|*@o$#&*373m@S`Yq_#!FCojuZf7PXsbvG-4)9y3L7vgFlk%Z36>D`l#$M; zD%gSdHa?H|Y}UxvY$?d4>vyAbu!EADrzTh5P;a|A5ONS@+TVBarp&raL(N@@^#v^v z^Db)YU{buf>hajyx`vKwS9%?>#V=FIi1LX(#p~~q*WzwkSh>d)o`EJk)(7M$<953; z%^%nkl24{R!7mrypRV+O)o)4M{Nk*Ov(_5sqB4P6{7pv_nsOnPVP_HLuvA^IEB?MC zUea(*U>(EreYs~Xs=fL2L20n*I{Mb;Xt<#Dw%Z-<#=6Bf-HN_ls!YJXj{ji%Tk9st zYm&mT{?T-GF8tVhs6JB4PntlXy_~By(!?ZAw zQ9j}WmJ2Z`)#LtpQeUMs2d9Utr3tn*hUSysht;z)HDPhV95BG(9a{Bdi@MaB--x=! z_xB7fL~-i;)yobP@5U?SH>t{N#FVYE0Llb~j3x<}^;uc$Rdnx~@RiTnS=@c=xR7F3 zlqj`KdBMgg+|FeJX;U-ky$~ia;PGrdap%HzPygATS$i@2WOa|3!QYZ(pS$R1a_{6Y z7U$AVAw{SGtx!>K4y9QZ3L$_PZi zos()=2l0=XK>rczp5SljwXLG7&ilvTJBO`jde)AFbqxkjf3zh5d{`C#cpmCnhCkS{Z_n6I)Ts4P_kA3QmGj0w1SrOX;**>nDOinU-% zbg$1xT~2*7KQ1+Ui^pn~pin}4${?EvhD9XjH{kPJXjSb)LB$RG5g$ufs0 zBO6P~e4H{P6Y}+OX7}Ag#2^-j4MnCQn!Z7WZ2O{w-bM|4yT=(`R=Og=-;fXj7X|{baiact*}m}4BKfmGgg{iX zb!?cIbynv$CLp}=;x|~kS<`n3ul>g3U`GbdM1y{y>d(St*4=6vV!+|h;jc8f1`Y?8 zQ`qs?m_uYbJ!Ly8GVfVIXsZ#nsl0Ue^cXMHaI*jO35%e!wyk(&^u+mHnZ(6Ts;3LF z&+KYW&#gAIR~?y8ceeBw^@@w9mO}?@Rzj?mRRV}_VrEVzpX-LZ*+3Og1Z~NhD+#M= zdg*0$W#v_mKCM_S&FCh6o^aD_J;K8RcZVnKHu$%JWkM^#jfz3WS2 z$y;Bp_f91?@ur!|l}My?dU_IMDag7ews+aNP;uCv33Tsruo&y3cBcN2*(fez6EFHh O_!F3;|5Ff{BYy`#s6hz; literal 0 HcmV?d00001 diff --git a/client/sandbox/react-nextjs/imgs/circle_red.jpg b/client/sandbox/react-nextjs/imgs/circle_red.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1c92891ed41161061c337c41c8dc7a1eb7723aff GIT binary patch literal 2543 zcmbW2cU03$7RP@fp#>BI!9*Vrr3Qnbpi=aKiUcW7jevBKE+|SbN)b^WWCI8h6hwlE zGzAussUXYS10&-u=s;SKUe0ZB8gDHZ^M z006`v0B-~^0tCSj2m~z14}yY%Lc-!A!hDku6B8AONk~b-Bw#SB_ z))#GT?d;v$Jv_axc>4qf1&4%&h2M<5doS+K_yl|^@j+Vp!$%pJxzF;R=NA+f6<55f ztg5bgUHhh`wXMCQlT3N{;Uo3a=YheY;j!_F$*Jj?*|~Y<3TyTIk2UuC#x@rS0RO_` z$6sLo#s%YZ3Giis2yb(N1VZ@^hCu}P9TJi{i4%75msZe<5rLmdd0O5qs;GT|A>$g* z58b77cubkOjrNo5zXQATf06wY>|b2NfH)Y$7Y_^r3;~V?9-SkA-(Ou&XzLxK!iaJz~j+vturV~(>kAb1P?uQyHiEC%otaFEAOpZ0lAMIZBaiU=VX#O zv)EUWM%izRAnPL0$FdmOQ}*AMM471bD^Zld{5kncOW)jH?U?pL(q<5Iu>`lGp0Lix z4o6!pKdh~CUq+TXjnTss&CX`#lUQ|8&=uz+8zk3$XLxiAi(SA2j7+#^9HNq_H?r}b@iZlt*|EK%dsb8d5iQ0=c&LN>9M#_tZd)p zuv%FUTuX<#H*rjQB7+CCc3M(LdTJl4dAKJgXgG$TXt5k}cG%Q%4rs z^_P6srW%cBXv?=2F6ZJg?+h5|Ts(lG8SIF-+1P))M7kr@aXv&sr>n>mW9omcBx{+h zVoi%32uK{HtrRleU?L1u^5r9_v}8>kieGq#Twi-@Mo^?Bon*o`s4{l3htJCwBocc@&J?ROei|{%{e5dl?PnYlx!f4 z`Wm(}D&sfm`@Q|}xTk-LTM@~}h7}HNeIyQY?nMN1Y<)<>C`3W!%JIg!(XXX4RiiH6Sw ze)h?Bub^|p^sT>tepqbp6QZ@Q6GBvCm{uO`#EKU{B>hf|5^QF%F-6TKSL~z8eNvU{ zw_b)GQok(>okt1*DOvJU9b#EG3#?Bc$!&mIWbT+dMO9qdFcR8NrV zvf1)o)k>fEO{(3FfwDy&ue1y(1UDXQFc(ayFGP~GOpr~L34tKdqD5r%vqCVjFc<2(0`>IE|ue?e5t_OUE^BQs+Gf3I{bzcK{bD~|`LT!?n zzn1l>!L*tOl+Hx*kNHgMb-5JIF$yz_Mn-7At*Vgv{d??RIJQL9Zug7gkD%sKgs~GH z4mWhWZ_3F$u{V1utNiMi)~2t8(Ee(LqOt;V+smP{BQInq|qd#>Dfq_`AX zEEj2hb?_dF`Ia@QPUtLbsoA}rW^!+!W!#U|N)4LolfsZa>k|{G#mzMx8~x?mpO1t) z*{udvABGU?a|2El&W6Z>SeLh`Wx2DZ*Un0%s~oRFtY`O_lO`(cxg(;-LeJSYT5r5C zc)vMMUFB?PY?#ZPeK-A%t{#5h@pL~q10cKruqTCQeX6437xb~j68nknXDPIT>fd2rZ~0Z!UI&Ww`PA??FLB>=WNG+D-W{~+&f>DctBHpH2YI@@3Z zxL#x~m?4bc^bJz%3o25sk*IK>7?_9EI?@B1_v$L!97sBG#8c20DAtO5I4c>o?oj*mMeITPw_P^Hk7Zdova^c)(V?lQK~D*;a{aW(#~JM#P(0|pFNs4^fy?Vs_Xy& literal 0 HcmV?d00001 diff --git a/client/sandbox/react-nextjs/imgs/square_blue.jpg b/client/sandbox/react-nextjs/imgs/square_blue.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ba2d7019cd93b1d91ca542c53535ca7d634018b8 GIT binary patch literal 1988 zcmd6lX;4#F6vyu)2??^qkOYtd88!ho+^A5Oh%|sp#fU8GNEE10aT=j2i@>W`wxCcz zL8=B7kz%VzK~ZKTP(aX15v;ORF$uCGI|TCbZXa6hjAN(EhfdF(|NV0B|IGQFb6_)k z4rXs~UhfPL1OS9M0JZ@qKp~OIWDN4mI22;;SpUE<0 zGZ+S@28PDQ91e%6Z)Ra;Vqs*$F_}q%5LT)>Rfk5?F<~)SCjU5KIbhI$Kkz3ZWP-F~ zs#!2=>Cw~+H1l$>FFEHF|?d(Wo>WOG`k3eJ;<|o= z`!)|xFYoO;{QN)N8xZ)}zAp|QIvg4n9&zle*tqzFL{VD$$x|7br?bAfm~$!ja^AQ3 zSAHlezHzhU*6q?CE32yS*3{m+-}va4$4{D?TUwM$5%iUUC&_(W|(m|&-6+s~1hE}>_fe>7%hrlKS#rBm+a8u7<2)H(?5ePbT z&;s}V`iIkhZA?+bZm+?xyvd&+_?rGdaCwY-^~cJEOvR-1TF&=jMecY*S-f{ft3zah ztjgEGdy%<^@vh`xaZpXYJ#+EB11ao;vqtV4pN0qp-USaDGv=`^@2B<`^Ldfm8`)D* zRV(uFr`)k*LlwQCp58{qH``WoCwd^bbfBq6%tmWvd8#Am^fsLZp+imi9fUa= z4|n9TwjggL2#zjCM_djGl?HPmXgm_3I9w)EeMz)Bxwm~{@h3#8iSZ#5O9*O30$EDu zq304&6rIQN8qMm9jSh=$=8gnM+dxp_rZSI{IQ5NIPE5r6sE4 zOtfe9ok(gTI%1Zo$eB79W-H5fYT&W4&IqYM%(+R&&I&~6z#|A;r3DYCu_4uUv_pou zL-2X_w2O9hmedPNo`*J-K#;puR)cCsh&K`hgIy(is_N0D46#|P;PY4QQget3& zg7~19w#{_h_2pFv{K)OoZbAVf6JQCsxHTT&qp8SyIL7Cp4&qy0UGW3QAh6PxWh)O7 zZL|*>TO(P;-Orh_m#`pc%fnME1((A-ATSqYj}Ji5v&Fu!z0xNyjlfV&y`Zr8Yu_rW zT8R*|HGVAV-+cyx^#gs@{BScL9@Yp!`DRZ3<2A+?%VZG5ty2U+;8?E*!Sh`b2n=%GlPNJ=e{>@R2>rje zy}N0gp<019+U+1~$25tRk+XZVX={uEQLR;W!RG_@cburZS}pfk+3uPwZDG(T4Cf^V v1ATRo*@nTFz0SN4YR3t)ipM>h+qL7)IgGmYcV84?kz;Bi>+^pmI&Aq3Nn@l| literal 0 HcmV?d00001 diff --git a/client/sandbox/react-nextjs/imgs/square_green.jpg b/client/sandbox/react-nextjs/imgs/square_green.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d366168869cafab4cecd0717596226853d4d1466 GIT binary patch literal 1936 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 ZKzQr-Sr4RQo}OJ_C+5zg{f)C>)o+oxw8a1b literal 0 HcmV?d00001 diff --git a/client/sandbox/react-nextjs/imgs/triangle:green copy.jpg b/client/sandbox/react-nextjs/imgs/triangle:green copy.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cf02bd5ad79c3c868d748926534d6387bcfdf53c GIT binary patch literal 2242 zcmbW2dpOkj9>>39W->01>BqP(X5D4BI!G>w2aCmV7eXcxm5vNyXv}1=m`Ei_?IzM? zDdZll`>hg3?YI-evSl%27~?wUSMBNaoIg%`_Vat*-{1KC8H=Sg;S7* zAUPE|1tlds9xo-Us;;W6uBePxe!mFF_@fX)NEF;9MnqH$o=~+D2qBP2A-I1S0G=HWe+MWCEvcewu|vu= zKvYn7<4>1y!$2H~fWt#Vz#MGsVQAkL`lO}BL;;ZAO^IX) zfYe(INxE{JdTn%@_X|2BbJUMAVJ5e*#T!razjN62k!as+Vx4Y&>iyJ|gN9Mt7S=e= zd(GB37sTiCci3MW_wr0ct3THokz?X>R7CKYwBSa^~~num=pr1zQde- zbJMDUnIGT3lhxmvPMs8KjVPi9f5Q*B+^Ccsn_EH{bSWSAq2*4^l6#8-ehB0^Sg$wE zJxQBzhQ`Pa?du__4+kjK?!qUQgs&$_pM56$)ur-TRQhxTVezHT`hdr)d4mJ;lsSi5 zH?sP$SDeZB?sSAaD%>WNThLIjmH9M~HzvVt_ByjMZyv?WQ92sE#bVd41s#pB(|Xx7 z8T$Mg$uC<;t72qeTZ9$P(;s4<)u^mq5deCl`neUHE72wXx~nw~d?v(nW0h7qZBl_x zUU#g3Jw5W~)DQ84Kshp^5T8vC{Cb~T&`U8f3PL{s`OEmyx$Uj(213JUDLjHFU`q75s4E{C4NK?nYQj>_lv-HVH52dEsfAEo?n#_Cx! z6X8{h%vEnc%Qm+|vc&E!0-AAAC+Sp4W1dR^gK+zusCL2m4_YD)XTi~CP)y@Bhd8v7 z#VRMW-vQS6i5vELv+gBY?B&(a{^Chu#<;EkB)wrN@g9jSl%>d2D+aD&oW9C-=jw*E zif8bDCv?9g`L`rrTYA0Oi$Bm2?<+oqXZY-8!lw3UGPFxG%~09}3^2mhN?mxW!P?Sq z^9*h9-IZ7wi|n=(tqV%b=sFxpw}~*8UY*H~4=p+wedEXUhN!!vyG$kjLHJq8YF9Hz zryHpmto0FHsb8d@pRO&jznC>NKPLdFbu;CTb0#H8)z`)w*-e*ouFfV4A}# zg!5G+V)1s>6yjX{ynALnK=y=rBrcWxJqSM@6hF+LsXy~&57J5*-6W!S$?(vL%pUIP zkbce{he@Bk+_hw!tDn{loU1jEuKDtLkYtduF1Yy?iuaf_&2O|%l9sxfrmr~P zOlTQs^co8wi9vQAM`-%X4`p6Hpx%TQjy}r9P#G{0r>J)&^4!mN#Bg92%85Q&qJ&TU%Z=N)(87ne2xc@UXPA@+=Q;7CY-?ltHgcSgVQb#^*($^f#55{BP zuGS1hhaadk57o^{*B$l1S$Bk literal 0 HcmV?d00001 diff --git a/client/sandbox/react-nextjs/imgs/triangle:red copy.jpg b/client/sandbox/react-nextjs/imgs/triangle:red copy.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2fda24c38657a58c2bf7b781696b4a8303d2dcde GIT binary patch literal 2377 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 DxejHN literal 0 HcmV?d00001 diff --git a/client/sandbox/react-nextjs/imgs/triangle_green.jpg b/client/sandbox/react-nextjs/imgs/triangle_green.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cf02bd5ad79c3c868d748926534d6387bcfdf53c GIT binary patch literal 2242 zcmbW2dpOkj9>>39W->01>BqP(X5D4BI!G>w2aCmV7eXcxm5vNyXv}1=m`Ei_?IzM? zDdZll`>hg3?YI-evSl%27~?wUSMBNaoIg%`_Vat*-{1KC8H=Sg;S7* zAUPE|1tlds9xo-Us;;W6uBePxe!mFF_@fX)NEF;9MnqH$o=~+D2qBP2A-I1S0G=HWe+MWCEvcewu|vu= zKvYn7<4>1y!$2H~fWt#Vz#MGsVQAkL`lO}BL;;ZAO^IX) zfYe(INxE{JdTn%@_X|2BbJUMAVJ5e*#T!razjN62k!as+Vx4Y&>iyJ|gN9Mt7S=e= zd(GB37sTiCci3MW_wr0ct3THokz?X>R7CKYwBSa^~~num=pr1zQde- zbJMDUnIGT3lhxmvPMs8KjVPi9f5Q*B+^Ccsn_EH{bSWSAq2*4^l6#8-ehB0^Sg$wE zJxQBzhQ`Pa?du__4+kjK?!qUQgs&$_pM56$)ur-TRQhxTVezHT`hdr)d4mJ;lsSi5 zH?sP$SDeZB?sSAaD%>WNThLIjmH9M~HzvVt_ByjMZyv?WQ92sE#bVd41s#pB(|Xx7 z8T$Mg$uC<;t72qeTZ9$P(;s4<)u^mq5deCl`neUHE72wXx~nw~d?v(nW0h7qZBl_x zUU#g3Jw5W~)DQ84Kshp^5T8vC{Cb~T&`U8f3PL{s`OEmyx$Uj(213JUDLjHFU`q75s4E{C4NK?nYQj>_lv-HVH52dEsfAEo?n#_Cx! z6X8{h%vEnc%Qm+|vc&E!0-AAAC+Sp4W1dR^gK+zusCL2m4_YD)XTi~CP)y@Bhd8v7 z#VRMW-vQS6i5vELv+gBY?B&(a{^Chu#<;EkB)wrN@g9jSl%>d2D+aD&oW9C-=jw*E zif8bDCv?9g`L`rrTYA0Oi$Bm2?<+oqXZY-8!lw3UGPFxG%~09}3^2mhN?mxW!P?Sq z^9*h9-IZ7wi|n=(tqV%b=sFxpw}~*8UY*H~4=p+wedEXUhN!!vyG$kjLHJq8YF9Hz zryHpmto0FHsb8d@pRO&jznC>NKPLdFbu;CTb0#H8)z`)w*-e*ouFfV4A}# zg!5G+V)1s>6yjX{ynALnK=y=rBrcWxJqSM@6hF+LsXy~&57J5*-6W!S$?(vL%pUIP zkbce{he@Bk+_hw!tDn{loU1jEuKDtLkYtduF1Yy?iuaf_&2O|%l9sxfrmr~P zOlTQs^co8wi9vQAM`-%X4`p6Hpx%TQjy}r9P#G{0r>J)&^4!mN#Bg92%85Q&qJ&TU%Z=N)(87ne2xc@UXPA@+=Q;7CY-?ltHgcSgVQb#^*($^f#55{BP zuGS1hhaadk57o^{*B$l1S$Bk literal 0 HcmV?d00001 diff --git a/client/sandbox/react-nextjs/imgs/triangle_red.jpg b/client/sandbox/react-nextjs/imgs/triangle_red.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2fda24c38657a58c2bf7b781696b4a8303d2dcde GIT binary patch literal 2377 zcmbu9X;c$g7RO%*F=07iNFszS%_1U;4hRCmKw}Fw_Ov+xBtk>dh=^!oKxOTa2#P3+ z2q=r%qy+_K6%N zeuxJ)?sIl=1_%TI2>1ff5cme5kg~F}NEE!FP^b-PMGP7aWd#K}MXa)l3RW45#ck45 z#o^TnSge|kn!1)2iA2Jw>gwxi>uYM0wAYFt;8)Qb&`KDLk~SWT*Z!{qdJ3=@a2Om$ zB6NWa7J%7Nw$N z=C}bz@kQ%~;LT%FZ({Uzmp2exy9N3?4xNmZli#GOMpWNyu*Gnzk%i?>D{Grw-|TU6 z-utb~J~#IR9@K-LUVexDj|3bIJa+1IXjpheWYpPnap&VNTuewyzj8GrGb{UAZr-ii z`2~0G7FOJUP+3*|@KH@;Q*%peTYJZ|S3SM2`?&oBgCnE-vG?N>A0|J}eHP9yEG{jJ zR@S%>0Qnmhy#5CE7cMN!B?E7ZEP9O#ArlS@iIqj^m~BvTq@aC6aJuF(82s+ko8=91 zdOKVNghMC00-(IBM^rV!?cTb$HvAwTZjmzM! z)EU@SRD>76!k^svB5^f5x-ewvM-bWd>@v$k zF6peeGvzVuK3#zsG?yYVfWXF2l}G8J=d3I5@ktMN2fr!gWGC9I9cHxo(vAcrEaFY7 zGdSbXFXBb2i}^R~RZ;%8 z)^Fl6t@Q)+Qt8ow)V`Yh?7+MI&nLWm8q*sLUOn#aHNUYD)2F)&Wc%QMY+TQ4BLv?I6I-9LB9@B5#7 z=QD5o0D&PoZ|K9%Gk6N!m8FQMIDtPa(rRWepS0{;r){79({epGe6WBN2GBR&+(p0t zF{|C7wq0B~_d2XzgXsqWx%y_?886jgUnSF@tlMTc-mo$%7(evlJW*q=pGu(PvJcgj z`&3qRvuOeI5I~L5`v<(Yc6r@eos6pEZ1$fD?=@yT&C#n7WG2qPz;Z9D?$LE*s*|O#$yqOh$v(Zl< z=LJ`Os$7cj-rek)?8Fs1TsbwJn@`DdZzNAYAw@iK6IV~q#wHk+o^6vi%ssQN&y2y8 zRtG^c(=OUzV2)k-sdB&lG*GsAN4mMF~Li{DhG z>9>U0uW{8)Xco|S){U&8#P7rATosxV4)tGk8b zM3^g>zYO~oQOzS`haXPqOQ?m%l?u9%Qb8arwHsl;f7>)vB( zX-i~IefQ8cLVu7Nwd=fFEP^N-?0j5USa)+ZMdG(OtR!rpGO{16ay`65V}_Oz8~N{3 zV>-)rQ~E6@-B0`9AL2^oM2w3f)xO}=&cL#$oHSt~TYOxi5v?4Zn_&Er+yMWW z^$}GzSiK{G&4A%HwY>`BsIJV4-TAXmf$;j(uO!7dB6=yC6jryZl*?x%u}7?y+QLJGcXH(?Pd9t0_ zGgkcM#7Z2V;*IV@;VECEf|ha3;WU*wR`#f|<-5&S8V2oR#&u9W=8mmnCXX8QY%~*^ za|_Kw($a>B)V%#qY+Ub@5e=@*<|LDHYYr@w-p%(q7G^H$%M89HaCuNVAF5a&D`Sq~ zCB42T*xnC;R)?UMpX>->qU+@0Q2(}rTJ;gN+w48nG}By!!PZ_&gW}q4I)}D8=T3<@ z&ukVcZibykzFQV)DYdk_t3!)x2M*PdANM5_HDx(L!m^Z>!`oOo(xJWupP(ZLJuv+0 zH2ftC?;DEEE~4y9E?Z0|&)9^d78z}`s1A7C6nf8Mhk9uFjM&5972+nTF3m~u3eF*? zf6@2y#2SQw=n)8Dx9ds+*}N?sSEzmLzt)!QAjhyjn2wSqn0N>zQ&y7`rt9F*xR9C3 zwx}22#@YYx`AM^dfMO4izM>G34gnb*2=EU~<^Vwn=d7Kja{Rn_XsF0qYmii#^}JPW z)<|A=?1V{VhbYDRsdgPTuG#l`GC58fFu{z>c~2Y*({ + ...config, + appId: APP_ID, +}) + +function Wrapper() { + const { isLoading, user, error } = db.useAuth(); + if (isLoading) { + return
Loading...
; + } + if (error) { + return
Uh oh! {error.message}
; + } + if (user) { + return ; + } + return ; +} + +function App() { + const { isLoading, error, data } = db.useQuery({ + $files: { + $: { + order: { serverCreatedAt: "asc" } + } + } + }) + if (isLoading) { + return
Fetching data...
+ } + if (error) { + return
Error fetching data: {error.message}
+ } + const { $files: images } = data + console.log("images", images) + return ( +
+
Image Feed
+ + +
+ ) +} + +async function uploadImage(file: File) { + try { + const res = await db.storage.upload2(file.name, file) + console.log('Upload response:', res) + } catch (error) { + console.error('Error uploading image:', error) + } +} + +async function deleteImage(image: Image) { + const val = await db.storage.delete(image.path) + console.log(val) +} + +interface SelectedFile { + file: File, previewURL: string +} + +function ImageUpload() { + const [selectedFile, setSelectedFile] = React.useState(null) + const { previewURL } = selectedFile || {} + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + const previewURL = URL.createObjectURL(file) + setSelectedFile({ file, previewURL }) + } + } + + const handleUpload = () => { + if (selectedFile) { + uploadImage(selectedFile.file) + URL.revokeObjectURL(selectedFile.previewURL) + setSelectedFile(null) + } + } + + return ( +
+ + {previewURL && ( +
+ Preview + +
+ )} +
+ ) +} + +function ImageGrid({ images }: { images: Image[] }) { + return ( +
+ {images.map((image, idx) => ( +
+ {image.path} +
+ {image.path} + deleteImage(image)} style={styles.delete}> + 𝘟 + +
+
+ ))} +
+ ) +} + +// Styles +// ---------- +const styles: Record = { + previewContainer: { + marginTop: '20px', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '10px', + }, + previewImage: { + maxWidth: '200px', + maxHeight: '200px', + objectFit: 'contain', + }, + uploadButton: { + padding: '8px 16px', + backgroundColor: '#4CAF50', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + fontFamily: 'code, monospace', + }, + container: { + boxSizing: 'border-box', + backgroundColor: '#fafafa', + fontFamily: 'code, monospace', + minHeight: '100vh', + padding: '20px', + display: 'flex', + alignItems: 'center', + flexDirection: 'column', + }, + header: { + letterSpacing: '2px', + fontSize: '50px', + color: 'lightgray', + marginBottom: '30px', + }, + uploadContainer: { + marginBottom: '30px', + padding: '20px', + border: '2px dashed lightgray', + borderRadius: '8px', + }, + fileInput: { + fontFamily: 'code, monospace', + }, + imageGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', + gap: '20px', + width: '100%', + maxWidth: '1200px', + }, + imageContainer: { + border: '1px solid lightgray', + borderRadius: '8px', + overflow: 'hidden', + }, + image: { + width: '100%', + height: '300px', + objectFit: 'cover', + }, + imageCaption: { + padding: '10px', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: 'white', + }, + delete: { + cursor: 'pointer', + color: 'lightgray', + padding: '0 5px', + }, +} + +export default Wrapper diff --git a/client/www/components/dash/Billing.tsx b/client/www/components/dash/Billing.tsx index 2f37e45ad..9e84074d4 100644 --- a/client/www/components/dash/Billing.tsx +++ b/client/www/components/dash/Billing.tsx @@ -198,6 +198,18 @@ export default function Billing({ appId }: { appId: string }) { +
+ {totalAppBytes > 0 && + + DB ({friendlyUsage(totalAppBytes)}) + + } + {totalStorageBytes > 0 && + + Storage ({friendlyUsage(totalStorageBytes)}) + + } +
{isFreeTier ? (
diff --git a/client/www/components/dash/Storage.tsx b/client/www/components/dash/Storage.tsx index 17199b777..832c864c7 100644 --- a/client/www/components/dash/Storage.tsx +++ b/client/www/components/dash/Storage.tsx @@ -1,706 +1,60 @@ -import React, { - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react'; -import { ChevronRightIcon } from '@heroicons/react/outline'; -import format from 'date-fns/format'; - -import config from '@/lib/config'; import { InstantApp } from '@/lib/types'; -import { jsonFetch } from '@/lib/fetch'; -import { Button, Checkbox, cn, SectionHeading } from '@/components/ui'; -import { TokenContext } from '@/lib/contexts'; -import { errorToast, successToast } from '@/lib/toast'; - -type StorageObject = { - key: string; - size: number; - owner: string; - etag: string; - last_modified: number; -}; - -type StorageFile = { - id: string; - key: string; - etag: string; - size: number; - path: string; - name: string; - lastModified: number; -}; - -type StorageDirectory = { - name: string; - size: number; - lastModified: number; - files: StorageFile[]; -}; - -async function fetchStorageFiles( - token: string, - appId: string, - subdirectory?: string -): Promise { - const qs = subdirectory ? `?subdirectory=${subdirectory}` : ''; - const { data } = await jsonFetch( - `${config.apiURI}/dash/apps/${appId}/storage/files${qs}`, - { - method: 'GET', - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${token}`, - }, - } - ); - - return data; -} - -async function deleteStorageFile( - token: string, - appId: string, - filename: string -): Promise { - const { data } = await jsonFetch( - `${ - config.apiURI - }/dash/apps/${appId}/storage/files?filename=${encodeURIComponent( - filename - )}`, - { - method: 'DELETE', - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${token}`, - }, - } - ); - - return data; -} - -async function bulkDeleteFiles( - token: string, - appId: string, - filenames: string[] -): Promise { - const { data } = await jsonFetch( - `${config.apiURI}/dash/apps/${appId}/storage/files/delete`, - { - method: 'POST', - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ filenames }), - } - ); - - return data; -} - -async function fetchDownloadUrl( - token: string, - appId: string, - filename: string -): Promise { - const { data } = await jsonFetch( - `${ - config.apiURI - }/dash/apps/${appId}/storage/signed-download-url?filename=${encodeURIComponent( - filename - )}`, - { - method: 'GET', - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${token}`, - }, - } - ); - - return data; -} - -async function upload( - token: string, - appId: string, - file: File -): Promise { - const fileName = file.name; - const { data: presignedUrl } = await jsonFetch( - `${config.apiURI}/dash/apps/${appId}/storage/signed-upload-url`, - { - method: 'POST', - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ app_id: appId, filename: fileName }), - } - ); - - const response = await fetch(presignedUrl, { - method: 'PUT', - body: file, - headers: { - 'Content-Type': file.type, - }, - }); - - return response.ok; -} - -const formatObjectKey = (file: StorageFile) => - [file.path, file.name].filter((str) => !!str).join('/'); - -function formatFileSize(size: number) { - const kb = 1000; - const mb = kb * 1000; - const gb = mb * 1000; - - if (size < kb) { - return `${size}B`; - } else if (size < mb) { - return `${(size / kb).toFixed(2)}KB`; - } else if (size < gb) { - return `${(size / mb).toFixed(2)}MB`; - } else { - return `${(size / gb).toFixed(2)}GB`; - } -} - -function useStorageFiles( - token: string, - appId: string, - subdirectory: string = '' -): [StorageFile[], StorageDirectory[], boolean, any, () => Promise] { - const [isLoading, setIsLoading] = useState(true); - const [files, setFiles] = useState([]); - const [error, setError] = useState(null); - - const refresh = useCallback(async () => { - if (!appId || !token) { - return; - } - - try { - setIsLoading(true); - setError(null); - - const files = await fetchStorageFiles(token, appId, subdirectory); - const formatted = files.map((f) => { - const [appId, ...keys] = f.key.split('/'); - const name = keys[keys.length - 1]; - - return { - id: f.key, - key: f.key, - path: keys.slice(0, -1).join('/'), - name: name, - etag: f.etag, - size: f.size, - lastModified: f.last_modified, - }; - }); - - setFiles(formatted); - } catch (err) { - setError(err); - } finally { - setIsLoading(false); - } - }, [token, appId, subdirectory]); - - useEffect(() => { - refresh(); - // Poll for new files every 5s - const i = setInterval(() => refresh(), 5000); - - return () => clearInterval(i); - }, [token, appId, subdirectory]); - - const filesByDirectory = useMemo( - () => - files - .filter((f) => f.path.startsWith(subdirectory)) - .reduce((acc, f) => { - // check if the file is in a subdirectory -- - // if yes, group by that directory; if no, group with `$current` directory - const [directory = ''] = f.path - .replace(subdirectory, '') - .split('/') - .filter((str) => str.length > 0); - const key = directory.trim().length === 0 ? '$current' : directory; - - return { ...acc, [key]: (acc[key] || []).concat(f) }; - }, {} as Record), - [files, subdirectory] - ); - const directories = useMemo(() => { - return Object.entries(filesByDirectory) - .filter(([key]) => key !== '$current') - .map(([name, files]) => { - return { - name, - files: files, - size: files.reduce((total, f) => total + f.size, 0), - lastModified: Math.max(...files.map((f) => f.lastModified)), - }; - }) - .sort((a, b) => b.lastModified - a.lastModified); - }, [filesByDirectory]); - const currentFiles = (filesByDirectory.$current || []).sort( - (a, b) => b.lastModified - a.lastModified - ); - - return [currentFiles, directories, isLoading, error, refresh]; -} - -type SelectedRow = - | { - type: 'all'; - } - | { type: 'directory'; value: StorageDirectory } - | { type: 'file'; value: StorageFile }; - -export function StorageEnabledTab({ - className, - app, -}: { - className?: string; - app: InstantApp; -}) { - const token = useContext(TokenContext); - const [selectedFiles, setSelectedFiles] = useState([]); - const [selectedRows, setSelectedRows] = useState>( - {} - ); - const [subdirectoryPrefix, setSubdirectoryPrefix] = useState(''); - const [ - files = [], - directories = [], - isLoadingFiles, - filesError, - refreshFiles, - ] = useStorageFiles(token, app.id, subdirectoryPrefix); - const breadcrumbs = [ - '/[root]', - ...subdirectoryPrefix.split('/').filter((str) => !!str), - ]; - const hasSelectedRows = Object.keys(selectedRows).length > 0; - - const [uploadingFile, setUploadingFile] = useState(false); - - const handleUploadFile = async () => { - try { - setUploadingFile(true); - if (selectedFiles.length === 0) { - return; - } - - const [file] = selectedFiles; - const success = await upload(token, app.id, file); - - if (success) { - setSelectedFiles([]); - } - - await refreshFiles(); - successToast('Successfully uploaded!'); - } catch (err: any) { - console.error('Failed to upload:', err); - errorToast(`('Failed to upload: ${err.body.message}`); - } finally { - setUploadingFile(false); - } - }; - - const handleViewFile = async (file: StorageFile) => { - try { - const key = formatObjectKey(file); - const url = await fetchDownloadUrl(token, app.id, key); - console.debug(url); - window.open(url, '_blank'); - } catch (err: any) { - console.error('Failed to download file:', err); - errorToast(`Failed to download file: ${err.body.message}`); - } - }; - - const handleDeleteFile = async (file: StorageFile) => { - const key = formatObjectKey(file); - - if (!confirm(`Are you sure you want to permanently delete\n"${key}"?`)) { - return; - } - - try { - await deleteStorageFile(token, app.id, key); - await refreshFiles(); - } catch (err: any) { - console.error('Failed to delete file:', err); - errorToast(`Failed to delete file: ${err.body.message}`); - } - }; - - const handleDeleteDirectory = async (directory: StorageDirectory) => { - const keys = directory.files.map((f) => formatObjectKey(f)); +import { Button, cn } from '@/components/ui'; +import { useRouter } from 'next/router'; - if ( - !confirm( - `Are you sure you want to permanently delete ${keys.length} files from ${directory.name}?` - ) - ) { - return; - } - - try { - await bulkDeleteFiles(token, app.id, keys); - await refreshFiles(); - } catch (err: any) { - console.error('Failed to delete directory:', err); - errorToast(`Failed to delete directory: ${err.body.message}`); - } - }; - - const getObjectKeysToDelete = () => { - if (selectedRows.all) { - return directories - .flatMap((dir) => dir.files) - .concat(files) - .map((f) => formatObjectKey(f)); - } else { - const selectedFiles = files.filter((file) => !!selectedRows[file.key]); - const selectedDirectories = directories.filter( - (dir) => !!selectedRows[dir.name] - ); - - return selectedDirectories - .flatMap((dir) => dir.files) - .concat(selectedFiles) - .map((f) => formatObjectKey(f)); - } - }; - - const handleBulkDelete = async () => { - const keys = getObjectKeysToDelete(); - - if ( - !confirm( - `Are you sure you want to permanently delete ${keys.length} ${ - keys.length === 1 ? 'file' : 'files' - }?` - ) - ) { - return; - } - - try { - await bulkDeleteFiles(token, app.id, keys); - await refreshFiles(); - - setSelectedRows({}); - } catch (err: any) { - console.error('Failed to bulk delete:', err); - errorToast(`Failed to bulk files: ${err.body.message}`); - } - }; - - return ( -
-
-
- Storage -
- {breadcrumbs.map((b, i, arr) => { - return ( - - {i > 0 && ( - - )} - - - ); - })} -
-
-
- ) => - setSelectedFiles(e.target.files) - } - /> - -
-
- - - - - {hasSelectedRows ? ( - - ) : ( - <> - - - - - )} - - - - - {directories.map((directory) => { - return ( - - - - - - - - ); - })} - {files.map((file) => ( - - - - - - - - ))} - - -
- - setSelectedRows((current) => { - if (checked) { - return { ...current, all: { type: 'all' } }; - } else { - const { all, ...updated } = current; - - return { ...updated }; - } - }) - } - /> - - - - Name - - Size - - Last modified -
- { - setSelectedRows((current) => { - if (checked) { - return { - ...current, - [directory.name]: { - type: 'directory', - value: directory, - }, - }; - } else { - delete current[directory.name]; - - return { ...current }; - } - }); - }} - /> - - - - {formatFileSize(directory.size)} - - {format(new Date(directory.lastModified), 'MMM dd, h:mma')} - -
- - -
-
- { - setSelectedRows((current) => { - if (checked) { - return { - ...current, - [file.key]: { type: 'file', value: file }, - }; - } else { - delete current[file.key]; - - return { ...current }; - } - }); - }} - /> - {file.name} - {formatFileSize(file.size)} - - {format(new Date(file.lastModified), 'MMM dd, h:mma')} - -
- - -
-
-
- ); -} - -function StorageDisabledTab({ +export function StorageTab({ className, app, + isEnabled, }: { className?: string; app: InstantApp; + isEnabled?: boolean; }) { + const router = useRouter(); return (

- Storage is not enabled for this app yet! + {isEnabled + ? "✈️ Storage has moved!" + : "Storage is not enabled for this app yet!" + }

- We're working on making storage just right, and can't wait to share it - with you. Are you interested in trying it out early? + {isEnabled + ? "Storage files are now visible in the `$files` namespace on the explorer." + : "We're working on making storage just right, and can't wait to share it with you. Are you interested in trying it out early?" + }

- - - + : + <> + + + + + }
); } - -export function StorageTab({ - className, - app, - isEnabled, -}: { - className?: string; - app: InstantApp; - isEnabled?: boolean; -}) { - if (isEnabled) { - return ; - } else { - return ; - } -} diff --git a/client/www/components/dash/explorer/Explorer.tsx b/client/www/components/dash/explorer/Explorer.tsx index be057d3ac..a065f4459 100644 --- a/client/www/components/dash/explorer/Explorer.tsx +++ b/client/www/components/dash/explorer/Explorer.tsx @@ -1,7 +1,9 @@ import { id, tx } from '@instantdb/core'; import { InstantReactWebDatabase } from '@instantdb/react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { isObject, debounce, last } from 'lodash'; +import { useCallback, useEffect, useMemo, useRef, useState, useContext } from 'react'; +import { jsonFetch } from '@/lib/fetch'; +import config from '@/lib/config'; import produce from 'immer'; import clsx from 'clsx'; import CopyToClipboard from 'react-copy-to-clipboard'; @@ -23,7 +25,7 @@ import { } from '@heroicons/react/solid'; import { PencilAltIcon } from '@heroicons/react/outline'; -import { errorToast } from '@/lib/toast'; +import { successToast, errorToast } from '@/lib/toast'; import { ActionButton, ActionForm, @@ -47,6 +49,7 @@ import { useNamespacesQuery, SearchFilter, } from '@/lib/hooks/explorer'; +import { TokenContext } from '@/lib/contexts'; import { EditNamespaceDialog } from '@/components/dash/explorer/EditNamespaceDialog'; import { EditRowDialog } from '@/components/dash/explorer/EditRowDialog'; import { useRouter } from 'next/router'; @@ -148,7 +151,7 @@ function queryToFilters({ JSON.parse(part.value), ], ]; - } catch (e) {} + } catch (e) { } } if (attr.checkedDataType === 'date') { try { @@ -175,7 +178,7 @@ function queryToFilters({ opToInstaqlOp(part.operator), JSON.parse(part.value), ]); - } catch (e) {} + } catch (e) { } break; } default: { @@ -217,6 +220,11 @@ function sameFilters( return false; } +const excludedSearchAttrs: [string, string][] = [ + // Exclude computed fields + ["$files", "url"] +]; + function SearchInput({ onSearchChange, attrs, @@ -257,9 +265,11 @@ function SearchInput({ const comboOptions: { field: string; operator: string; display: string }[] = ( attrs || [] ).flatMap((a) => { - if (a.type === 'ref') { + const isExcluded = excludedSearchAttrs.some(([ns, name]) => ns === a.namespace && name === a.name); + if (a.type === 'ref' || isExcluded) { return []; } + const ops = []; const opCandidates = []; @@ -440,17 +450,17 @@ export function Explorer({ [namespaces, currentNav?.namespace], ); - const isSystemCatalogNs = - selectedNamespace != null && - selectedNamespace.name != null && - selectedNamespace.name.startsWith('$'); + // auth + const token = useContext(TokenContext); - const readOnlyNs = isSystemCatalogNs && selectedNamespace.name !== '$users'; + const isSystemCatalogNs = selectedNamespace?.name?.startsWith('$') ?? false; + const sanitizedNsName = selectedNamespace?.name ?? ''; + const readOnlyNs = isSystemCatalogNs && !['$users', '$files'].includes(sanitizedNsName); const [limit, setLimit] = useState(50); const [offsets, setOffsets] = useState<{ [namespace: string]: number }>({}); - const offset = offsets[selectedNamespace?.name ?? ''] || 0; + const offset = offsets[sanitizedNsName] || 0; const sortAttr = currentNav?.sortAttr || 'serverCreatedAt'; const sortAsc = currentNav?.sortAsc ?? true; @@ -495,7 +505,42 @@ export function Explorer({ () => allItems.find((i) => i.id === editableRowId), [allItems.length, editableRowId], ); - const rowText = Object.keys(checkedIds).length === 1 ? 'row' : 'rows'; + const rowText = + sanitizedNsName === '$files' + ? Object.keys(checkedIds).length === 1 ? 'file' : 'files' + : Object.keys(checkedIds).length === 1 ? 'row' : 'rows'; + + // Storage + + const [selectedFiles, setSelectedFiles] = useState([]); + const [uploadingFile, setUploadingFile] = useState(false); + const [customPath, setCustomPath] = useState(''); + const fileInputRef = useRef(null); + const handleUploadFile = async () => { + try { + setUploadingFile(true); + if (selectedFiles.length === 0) { + return; + } + + const [file] = selectedFiles; + const success = await upload(token, appId, file, customPath); + + if (success) { + setSelectedFiles([]); + setCustomPath(''); + fileInputRef.current && (fileInputRef.current.value = ''); + } + + // await refreshFiles(); + successToast('Successfully uploaded!'); + } catch (err: any) { + console.error('Failed to upload:', err); + errorToast(`('Failed to upload: ${err.body.message}`); + } finally { + setUploadingFile(false); + } + }; return (
@@ -525,11 +570,16 @@ export function Explorer({ } onClick={async () => { try { - await db.transact( - Object.keys(checkedIds).map((id) => - tx[selectedNamespace.name][id].delete(), - ), - ); + if (selectedNamespace.name === "$files") { + const filenames = allItems.filter(i => i.id in checkedIds).map(i => i.path as string); + await bulkDeleteFiles(token, appId, filenames) + } else { + await db.transact( + Object.keys(checkedIds).map((id) => + tx[selectedNamespace.name][id].delete(), + ), + ); + } } catch (error) { errorToast(`Failed to delete ${rowText}`); return; @@ -732,22 +782,65 @@ export function Explorer({
+ {selectedNamespace.name === "$files" + ? +
+
+
+ ) => { + const files = e.target.files; + setSelectedFiles(files); + if (files?.[0]) { + setCustomPath(files[0].name); + } + }} + /> + +
+
+ File Path: + setCustomPath(e.target.value)} + className="w-full h-9 rounded-md bg-transparent pl-24 pr-3 py-1 text-sm placeholder:text-zinc-500 outline outline-1 outline-zinc-200 focus:ring-2 focus:ring-blue-700 border-0" + /> +
+
+
+ : null}
- + {selectedNamespace.name !== "$files" + ? + + : null}