├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .husky ├── post-merge └── pre-commit ├── .node-version ├── .prettierignore ├── .prettierrc.js ├── .prettierrc.precommit.js ├── .xata └── migrations │ ├── .ledger │ ├── mig_cj4i6i85j5vio0h4uj6g_c706041e.json │ ├── mig_cj4i6jqf3va3kd8gisa0_b52f04ab.json │ ├── mig_cj4i6nnq80c62tvvf1ag_e836b520.json │ ├── mig_cj4i7kaf3va3kd8gisb0_525c4d48.json │ ├── mig_cj4i7ovq80c62tvvf1bg_63a06ee8.json │ ├── mig_cj4i8mnq80c62tvvf1cg_5fc1b40d.json │ ├── mig_cj4i8ug5j5vio0h4uj7g_be304ff4.json │ ├── mig_cj4i91fq80c62tvvf1dg_ebd7c2e0.json │ ├── mig_cj4iavaf3va3kd8gisc0_32305670.json │ ├── mig_cj4ib3qf3va3kd8gism0_002a83cf.json │ ├── mig_cj4ibfaf3va3kd8git70_b3917594.json │ └── mig_cj4ibmg5j5vio0h4ujf0_ed6fb1ec.json ├── .xatarc ├── README.md ├── app ├── api │ └── images │ │ ├── [imageId] │ │ └── route.ts │ │ ├── route.ts │ │ └── search │ │ └── route.ts ├── apple-touch-icon.png ├── favicon.ico ├── favicon.svg ├── images │ └── [id] │ │ └── page.tsx ├── layout.tsx ├── loading.tsx ├── page.tsx ├── providers.tsx └── tags │ └── [id] │ └── page.tsx ├── components ├── icons │ ├── discord.tsx │ ├── github.tsx │ ├── twitter.tsx │ └── xataWordmark.tsx ├── images │ ├── index.tsx │ ├── individual.tsx │ └── upload.tsx ├── layout │ ├── base.tsx │ └── loading.tsx └── search │ ├── index.tsx │ └── result.tsx ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── public ├── next.svg └── vercel.svg ├── sample-data ├── butterfly_blue_morpho.jpg ├── butterfly_chilling.jpg ├── butterfly_ghost.jpg ├── butterfly_hanging.jpg ├── butterfly_having_lunch.jpg ├── butterfly_munching.jpg ├── butterfly_on_another_red_flower.jpg ├── butterfly_on_finger.jpg ├── butterfly_on_green_plant.jpg ├── butterfly_on_ground.jpg ├── butterfly_on_leaf.jpg ├── butterfly_on_pink_flower.jpg ├── butterfly_on_red_flower.jpg ├── butterfly_on_rocks.jpg ├── butterfly_polka_dot.jpg └── butterfly_sleeping.jpg ├── schema.json ├── scripts ├── bootstrap.mjs ├── cleanup.mjs ├── one-click.mjs └── seed.mjs ├── theme ├── globalstyles.ts ├── theme.ts └── tokens.ts ├── tsconfig.json └── utils ├── constants.ts ├── metadata.ts └── xata.ts /.env.example: -------------------------------------------------------------------------------- 1 | # Xata credentials 2 | XATA_BRANCH=main 3 | XATA_API_KEY= 4 | 5 | # Setting to true will disable API / UI to write to the database 6 | READ_ONLY=false 7 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .eslintrc.js 3 | next.config.js 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import("eslint").Linter.Config} 3 | */ 4 | module.exports = { 5 | parser: '@typescript-eslint/parser', 6 | 7 | parserOptions: { 8 | ecmaVersion: 2020, 9 | sourceType: 'module', 10 | project: 'tsconfig.json' 11 | }, 12 | extends: [ 13 | 'plugin:@typescript-eslint/recommended', 14 | 'plugin:prettier/recommended', 15 | 'plugin:jsx-a11y/recommended', 16 | 'prettier', 17 | 'next', 18 | 'next/core-web-vitals', 19 | 'plugin:jsx-a11y/recommended' 20 | ], 21 | plugins: ['react', 'jsx-a11y', 'check-file'], 22 | rules: { 23 | 'no-unused-expressions': ['error', { enforceForJSX: true }], 24 | 'import/no-default-export': 0, 25 | 'import/no-anonymous-default-export': 0, 26 | '@typescript-eslint/ban-ts-comment': 'off', 27 | '@typescript-eslint/no-var-requires': 0, 28 | '@typescript-eslint/explicit-module-boundary-types': 0, 29 | 'jsx-a11y/no-autofocus': 0, 30 | 'jsx-a11y/alt-text': 0, 31 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', ignoreRestSiblings: true }], 32 | '@typescript-eslint/no-floating-promises': 'error', 33 | '@typescript-eslint/no-explicit-any': 'off', 34 | 'react/jsx-curly-brace-presence': [1, { props: 'never', propElementValues: 'always' }], 35 | 'no-restricted-imports': [ 36 | 'error', 37 | { 38 | paths: [ 39 | { 40 | name: 'lodash', 41 | importNames: ['default'], 42 | message: 43 | "Please import only the functions you need to reduce bundle size. E.g. import { range } from 'lodash'" 44 | } 45 | ] 46 | } 47 | ] 48 | }, 49 | overrides: [ 50 | { 51 | files: ['*.test.ts{,x}'], 52 | rules: { 53 | 'no-restricted-imports': 0 // this are purely for bundle size 54 | } 55 | }, 56 | { 57 | files: ['*.stories.ts{,x}'], 58 | rules: { 59 | 'import/no-default-export': 0 // storybook uses default export 60 | } 61 | } 62 | ] 63 | }; 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | .eslintcache 38 | 39 | 40 | .env 41 | -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | function changed { 2 | git diff --name-only HEAD@{1} HEAD | grep "^$1" > /dev/null 2>&1 3 | } 4 | 5 | if changed 'package-lock.json'; then 6 | echo "Lockfile changes detected. Installing updates..." 7 | pnpm install 8 | fi 9 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | ./node_modules/.bin/lint-staged 2 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 18.16.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/.git 2 | **/.svn 3 | **/.hg 4 | **/node_modules 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | singleQuote: true, 4 | semi: true, 5 | trailingComma: 'none', 6 | printWidth: 120, 7 | endOfLine: 'auto', 8 | plugins: ['prettier-plugin-organize-imports'], 9 | organizeImportsSkipDestructiveCodeActions: true 10 | }; 11 | -------------------------------------------------------------------------------- /.prettierrc.precommit.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('./.prettierrc'), 3 | organizeImportsSkipDestructiveCodeActions: false 4 | }; 5 | -------------------------------------------------------------------------------- /.xata/migrations/.ledger: -------------------------------------------------------------------------------- 1 | mig_cj4i6i85j5vio0h4uj6g_c706041e 2 | mig_cj4i6jqf3va3kd8gisa0_b52f04ab 3 | mig_cj4i6nnq80c62tvvf1ag_e836b520 4 | mig_cj4i7kaf3va3kd8gisb0_525c4d48 5 | mig_cj4i7ovq80c62tvvf1bg_63a06ee8 6 | mig_cj4i8mnq80c62tvvf1cg_5fc1b40d 7 | mig_cj4i8ug5j5vio0h4uj7g_be304ff4 8 | mig_cj4i91fq80c62tvvf1dg_ebd7c2e0 9 | mig_cj4iavaf3va3kd8gisc0_32305670 10 | mig_cj4ib3qf3va3kd8gism0_002a83cf 11 | mig_cj4ibfaf3va3kd8git70_b3917594 12 | mig_cj4ibmg5j5vio0h4ujf0_ed6fb1ec 13 | -------------------------------------------------------------------------------- /.xata/migrations/mig_cj4i6i85j5vio0h4uj6g_c706041e.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mig_cj4i6i85j5vio0h4uj6g", 3 | "checksum": "1:c706041e4f08a2291ac68ee432f8e03aead48e5844c46d8386c5061165658be9", 4 | "operations": [ 5 | { 6 | "addTable": { 7 | "table": "gallery" 8 | } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.xata/migrations/mig_cj4i6jqf3va3kd8gisa0_b52f04ab.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mig_cj4i6jqf3va3kd8gisa0", 3 | "parentID": "mig_cj4i6i85j5vio0h4uj6g", 4 | "checksum": "1:b52f04ab0eb94583299d1574af88f6b2d944ac9fadb66a9ed6aea6bedf0f198a", 5 | "operations": [ 6 | { 7 | "addTable": { 8 | "table": "image" 9 | } 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.xata/migrations/mig_cj4i6nnq80c62tvvf1ag_e836b520.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mig_cj4i6nnq80c62tvvf1ag", 3 | "parentID": "mig_cj4i6jqf3va3kd8gisa0", 4 | "checksum": "1:e836b5207c0487b8bedbc2a8e2f341e0b41f851cdc55090f8702c04ec8f7848c", 5 | "operations": [ 6 | { 7 | "addTable": { 8 | "table": "gallery-to-image" 9 | } 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.xata/migrations/mig_cj4i7kaf3va3kd8gisb0_525c4d48.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mig_cj4i7kaf3va3kd8gisb0", 3 | "parentID": "mig_cj4i6nnq80c62tvvf1ag", 4 | "checksum": "1:525c4d4833941c358aff5d14eebfd1ecf267239ead56fd0487363fe3189ad5f1", 5 | "operations": [ 6 | { 7 | "addColumn": { 8 | "column": { 9 | "name": "name", 10 | "type": "string", 11 | "notNull": true, 12 | "defaultValue": "Image" 13 | }, 14 | "table": "image" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.xata/migrations/mig_cj4i7ovq80c62tvvf1bg_63a06ee8.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mig_cj4i7ovq80c62tvvf1bg", 3 | "parentID": "mig_cj4i7kaf3va3kd8gisb0", 4 | "checksum": "1:63a06ee8f83412bf8a1a3b8a166f29152db661da92b7800fcccfa9bcd24f7c3d", 5 | "operations": [ 6 | { 7 | "addColumn": { 8 | "column": { 9 | "name": "image", 10 | "type": "file" 11 | }, 12 | "table": "image" 13 | } 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.xata/migrations/mig_cj4i8mnq80c62tvvf1cg_5fc1b40d.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mig_cj4i8mnq80c62tvvf1cg", 3 | "parentID": "mig_cj4i7ovq80c62tvvf1bg", 4 | "checksum": "1:5fc1b40d8d859774791d77cc6a11fca6454f10914a10a34cd8f46986bea5c626", 5 | "operations": [ 6 | { 7 | "addColumn": { 8 | "column": { 9 | "name": "name", 10 | "type": "string", 11 | "notNull": true, 12 | "defaultValue": "gallery" 13 | }, 14 | "table": "gallery" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.xata/migrations/mig_cj4i8ug5j5vio0h4uj7g_be304ff4.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mig_cj4i8ug5j5vio0h4uj7g", 3 | "parentID": "mig_cj4i8mnq80c62tvvf1cg", 4 | "checksum": "1:be304ff4c9eea082635d18d9322e32cbab0adadf1d268e66b9b025e0008cbe72", 5 | "operations": [ 6 | { 7 | "addColumn": { 8 | "column": { 9 | "name": "gallery", 10 | "type": "link", 11 | "link": { 12 | "table": "gallery" 13 | } 14 | }, 15 | "table": "gallery-to-image" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.xata/migrations/mig_cj4i91fq80c62tvvf1dg_ebd7c2e0.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mig_cj4i91fq80c62tvvf1dg", 3 | "parentID": "mig_cj4i8ug5j5vio0h4uj7g", 4 | "checksum": "1:ebd7c2e0553a4ca039bf2a4bd4b4c4f72a375dcfb36ca0e73ebf81b6d318ef56", 5 | "operations": [ 6 | { 7 | "addColumn": { 8 | "column": { 9 | "name": "image", 10 | "type": "link", 11 | "link": { 12 | "table": "image" 13 | } 14 | }, 15 | "table": "gallery-to-image" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.xata/migrations/mig_cj4iavaf3va3kd8gisc0_32305670.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mig_cj4iavaf3va3kd8gisc0", 3 | "parentID": "mig_cj4i91fq80c62tvvf1dg", 4 | "checksum": "1:32305670769a2505bb2e2bd05c726d7563866ec5ab7d4f52d4413c9d0b968cff", 5 | "operations": [ 6 | { 7 | "renameTable": { 8 | "newName": "tag", 9 | "oldName": "gallery" 10 | } 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.xata/migrations/mig_cj4ib3qf3va3kd8gism0_002a83cf.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mig_cj4ib3qf3va3kd8gism0", 3 | "parentID": "mig_cj4iavaf3va3kd8gisc0", 4 | "checksum": "1:002a83cf4b1968fdc6df987c98245c22b47a2a9f4c5e3a27c97273d3663e4b52", 5 | "operations": [ 6 | { 7 | "renameTable": { 8 | "newName": "tag-to-image", 9 | "oldName": "gallery-to-image" 10 | } 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.xata/migrations/mig_cj4ibfaf3va3kd8git70_b3917594.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mig_cj4ibfaf3va3kd8git70", 3 | "parentID": "mig_cj4ib3qf3va3kd8gism0", 4 | "checksum": "1:b3917594c7da860366d96368c4383b9e6830fe8b08944c6e96c55e6f993dc57b", 5 | "operations": [ 6 | { 7 | "removeColumn": { 8 | "column": "gallery", 9 | "table": "tag-to-image" 10 | } 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.xata/migrations/mig_cj4ibmg5j5vio0h4ujf0_ed6fb1ec.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mig_cj4ibmg5j5vio0h4ujf0", 3 | "parentID": "mig_cj4ibfaf3va3kd8git70", 4 | "checksum": "1:ed6fb1ece3c27c1cdc197d8da2b399e81a33c95e1f4bf784e437d666af8cc874", 5 | "operations": [ 6 | { 7 | "addColumn": { 8 | "column": { 9 | "name": "tag", 10 | "type": "link", 11 | "link": { 12 | "table": "tag" 13 | } 14 | }, 15 | "table": "tag-to-image" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.xatarc: -------------------------------------------------------------------------------- 1 | { 2 | "databaseURL": "https://sample-databases-v0sn1n.us-east-1.xata.sh/db/gallery-example", 3 | "codegen": { 4 | "output": "utils/xata.ts" 5 | } 6 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Live demo 2 | 3 | A live demo of this application is available at [https://xata-gallery.vercel.app](https://xata-gallery.vercel.app). The demo turns off the ability to upload and delete images. For local or forked versions, set the `.env` setting `READ_ONLY=false`. 4 | 5 | ## Sample gallery app powered by Xata 6 | 7 | A small example Xata application built with Next.js & Chakra UI. 8 | 9 | ![image](https://github.com/xataio/sample-nextjs-chakra-gallery-app/assets/324519/47727874-318f-4451-a670-f456e85a09df) 10 | 11 | This app showcases serveral [Xata](https://xata.io) features including: 12 | 13 | - Offset based pagination 14 | - Form management and submission 15 | - Search 16 | - Aggregations 17 | - Summaries 18 | - Image transformations 19 | - Queries using junction tables and links 20 | - Proper Next.js + Xata TypeScript patterns 21 | 22 | ## To run this example locally with your own database 23 | 24 | You'll need to [install Xata](https://xata.io/docs/getting-started/installation) before performing these steps. 25 | 26 | - `git clone git@github.com:xataio/sample-nextjs-chakra-gallery-app.git` 27 | - `cd sample-nextjs-chakra-gallery-app` 28 | - `pnpm install` 29 | - Run `pnpm run bootstrap` the first you set up the project. This will ask for a Xata database to install to (you can create a new one) and then seed in some data. 30 | - `pnpm run dev` to load the site at http://localhost:3000 31 | - Add images either through the application, or through your database UI at https://app.xata.io 32 | 33 | ## Environment variables 34 | 35 | After you run init, your `.env` file should look like this 36 | 37 | ```bash 38 | # Xata credentials 39 | XATA_BRANCH=main 40 | XATA_API_KEY= 41 | 42 | # Setting to true will disable API / UI to write to the database 43 | READ_ONLY=false 44 | ``` 45 | 46 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fxataio%2Fsample-nextjs-chakra-gallery-app&integration-ids=oac_IDpMECDuYqBvAtu3wXXMQe0J&install-command=pnpm%20xata:one-click) 47 | -------------------------------------------------------------------------------- /app/api/images/[imageId]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { getXataClient } from '~/utils/xata'; 3 | 4 | const xata = getXataClient(); 5 | 6 | // Next.js edge runtime 7 | // https://nextjs.org/docs/pages/api-reference/edge 8 | export const runtime = 'edge'; 9 | export const preferredRegion = 'iad1'; 10 | 11 | // A delete function to handle requests to delete an image 12 | export async function DELETE(request: Request, { params }: { params: { imageId: string } }) { 13 | const { imageId } = params; 14 | if (!imageId) { 15 | return NextResponse.json( 16 | { message: 'imageId not found' }, 17 | { 18 | status: 404 19 | } 20 | ); 21 | } 22 | // People on the internet can be mean, so let's make sure we don't allow 23 | // anyone to create images in the live demo 24 | if (process.env.READ_ONLY === 'true') { 25 | return NextResponse.json( 26 | { message: 'Read only mode enabled' }, 27 | { 28 | status: 403 29 | } 30 | ); 31 | } 32 | 33 | // FInd all the tag links to this image 34 | const linksFromImage = await xata.db['tag-to-image'] 35 | .filter({ 36 | 'image.id': imageId 37 | }) 38 | .getAll(); 39 | 40 | // Delete all tag links to this image first 41 | await xata.db['tag-to-image'].delete(linksFromImage.map((link) => link.id)); 42 | // Delete the image 43 | await xata.db.image.delete(imageId); 44 | 45 | return NextResponse.json({ success: true }); 46 | } 47 | -------------------------------------------------------------------------------- /app/api/images/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import slugify from 'slugify'; 3 | import { v4 as uuid } from 'uuid'; 4 | import { getXataClient } from '~/utils/xata'; 5 | 6 | const xata = getXataClient(); 7 | 8 | export async function POST(request: Request) { 9 | // People on the internet can be mean, so let's make sure we don't allow 10 | // anyone to create images in the live demo 11 | if (process.env.READ_ONLY === 'true') { 12 | return NextResponse.json({ message: 'Read only mode enabled' }, { status: 403 }); 13 | } 14 | 15 | // Get the form data 16 | const formData = await request.formData(); 17 | const fileType = formData.get('fileType') as string; 18 | const name = formData.get('name') as string; 19 | const tags = formData.get('tags') as string; 20 | 21 | // Split the tags into an array from a comma separated string 22 | const tagsArray = 23 | tags?.split(',').map((tag) => { 24 | const name = tag.trim(); 25 | return { id: slugify(name, { lower: true }), name }; 26 | }) ?? []; 27 | 28 | // Create an empty image record with no base64 content 29 | const record = await xata.db.image.create( 30 | { name, image: { name: name, mediaType: fileType, base64Content: '' } }, 31 | // Request an uploadUrl from the created record. We can use it to upload a large to replace the dummy one 32 | ['*', 'image.uploadUrl'] 33 | ); 34 | 35 | // Once the image is created, create or update any related tags 36 | // Also create the links between the image and the tags 37 | if (tagsArray.length > 0) { 38 | await xata.db.tag.createOrUpdate(tagsArray); 39 | 40 | await xata.db['tag-to-image'].create( 41 | // Create an array of objects with the tag id and image id 42 | tagsArray.map(({ id: tag }) => ({ id: uuid(), tag, image: record.id })) 43 | ); 44 | } 45 | 46 | // Xata provides a toSerializable() method to convert the record to a plain JSON object 47 | // This is needed for Next.js on the client side 48 | return NextResponse.json(record.toSerializable()); 49 | } 50 | -------------------------------------------------------------------------------- /app/api/images/search/route.ts: -------------------------------------------------------------------------------- 1 | import { getXataClient } from '~/utils/xata'; 2 | 3 | // Next.js edge runtime 4 | // https://nextjs.org/docs/pages/api-reference/edge 5 | export const runtime = 'edge'; 6 | export const preferredRegion = 'iad1'; 7 | 8 | const xata = getXataClient(); 9 | 10 | export async function GET(req: Request) { 11 | const { searchParams } = new URL(req.url); 12 | const searchQuery = searchParams.get('query') ?? ''; 13 | 14 | // Return results from the tag and image tables 15 | const { records } = await xata.search.all(searchQuery, { 16 | tables: [ 17 | { 18 | table: 'tag', 19 | target: [{ column: 'name' }] 20 | }, 21 | { 22 | table: 'image', 23 | target: [{ column: 'name' }] 24 | } 25 | ], 26 | // Add fuzzy search to the query to handle typos 27 | fuzziness: 1, 28 | prefix: 'phrase' 29 | }); 30 | 31 | // Return the results as JSON 32 | return new Response(JSON.stringify(records), { 33 | headers: { 'Cache-Control': 'max-age=1, stale-while-revalidate=300' } 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /app/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/app/apple-touch-icon.png -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/app/favicon.ico -------------------------------------------------------------------------------- /app/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/images/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { JSONData } from '@xata.io/client'; 2 | import { compact } from 'lodash'; 3 | import { notFound } from 'next/navigation'; 4 | import { Image } from '~/components/images/individual'; 5 | import { ImageRecord, TagRecord, getXataClient } from '~/utils/xata'; 6 | 7 | const xata = getXataClient(); 8 | 9 | const getImage = async (id: string) => { 10 | const image = (await xata.db.image.read(id)) as ImageRecord; 11 | if (!image?.image) { 12 | return undefined; 13 | } 14 | return image.toSerializable(); 15 | }; 16 | 17 | export default async function Page({ params: { id } }: { params: { id: string } }) { 18 | const image = await getImage(id); 19 | if (!image) { 20 | notFound(); 21 | } 22 | const tagsFromImage = await xata.db['tag-to-image'] 23 | .filter({ 24 | 'image.id': id 25 | }) 26 | .select(['*', 'tag.*']) 27 | .getMany(); 28 | 29 | const tags = compact(tagsFromImage.map((tag) => tag.tag?.toSerializable())) as JSONData[]; 30 | 31 | const readOnly = process.env.READ_ONLY === 'true'; 32 | 33 | return ; 34 | } 35 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Inter } from 'next/font/google'; 3 | import { BaseLayout } from '~/components/layout/base'; 4 | import { Providers } from './providers'; 5 | 6 | const inter = Inter({ subsets: ['latin'] }); 7 | 8 | // Next.js edge runtime 9 | // https://nextjs.org/docs/pages/api-reference/edge 10 | export const runtime = 'edge'; 11 | export const preferredRegion = 'iad1'; 12 | 13 | export const metadata: Metadata = { 14 | title: 'Xata Gallery Sample App', 15 | description: 'Xata gallery sample app' 16 | }; 17 | 18 | export default function RootLayout({ children }: { children: React.ReactNode }) { 19 | return ( 20 | 21 | 22 | 23 | {children} 24 | 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { HeaderLoading } from '~/components/layout/loading'; 2 | 3 | export default function Loading() { 4 | const readOnly = process.env.READ_ONLY === 'true'; 5 | 6 | // You can add any UI inside Loading, including a Skeleton. 7 | return ; 8 | } 9 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { compact, pick } from 'lodash'; 2 | import { Images, TagWithImageCount } from '~/components/images'; 3 | import { IMAGES_PER_PAGE_COUNT, IMAGE_SIZE } from '~/utils/constants'; 4 | import { getXataClient } from '~/utils/xata'; 5 | 6 | const xata = getXataClient(); 7 | 8 | // Using Xata's aggregate helper, we can get the total number of images 9 | const getImageCount = async () => { 10 | const totalNumberOfImages = await xata.db.image.summarize({ 11 | columns: [], 12 | summaries: { 13 | count: { count: '*' } 14 | } 15 | }); 16 | return totalNumberOfImages.summaries[0].count; 17 | }; 18 | 19 | export default async function Page({ searchParams }: { searchParams: { page: string } }) { 20 | const pageNumber = parseInt(searchParams.page) || 1; 21 | 22 | // We use Xata's getPaginated helper to get a paginated list of images, sorted by date 23 | const imagesPagePromise = xata.db.image.sort('xata.createdAt', 'desc').getPaginated({ 24 | pagination: { size: IMAGES_PER_PAGE_COUNT, offset: IMAGES_PER_PAGE_COUNT * pageNumber - IMAGES_PER_PAGE_COUNT } 25 | }); 26 | 27 | const imageCountPromise = getImageCount(); 28 | 29 | // We use Xata's summarize helper to get the top 10 tags, 30 | // and create a property for each tag called imageCount 31 | const topTagsPromise = xata.db['tag-to-image'].summarize({ 32 | columns: ['tag'], 33 | summaries: { 34 | imageCount: { count: '*' } 35 | }, 36 | sort: [ 37 | { 38 | imageCount: 'desc' 39 | } 40 | ], 41 | pagination: { 42 | size: 10 43 | } 44 | }); 45 | 46 | console.time('Fetching images'); 47 | const [imagesPage, imageCount, topTags] = await Promise.all([imagesPagePromise, imageCountPromise, topTagsPromise]); 48 | console.timeEnd('Fetching images'); 49 | 50 | const totalNumberOfPages = Math.ceil(imageCount / IMAGES_PER_PAGE_COUNT); 51 | 52 | // This page object is needed for building the buttons in the pagination component 53 | const page = { 54 | pageNumber, 55 | hasNextPage: imagesPage.hasNextPage(), 56 | hasPreviousPage: pageNumber > 1, 57 | totalNumberOfPages 58 | }; 59 | 60 | // We use Xata's transform helper to create a thumbnail for each image 61 | // and apply it to the image object 62 | console.time('Fetching images transforms'); 63 | const images = compact( 64 | await Promise.all( 65 | imagesPage.records.map(async (record) => { 66 | if (!record.image) { 67 | return undefined; 68 | } 69 | 70 | const { url } = record.image.transform({ 71 | width: IMAGE_SIZE, 72 | height: IMAGE_SIZE, 73 | format: 'auto', 74 | fit: 'cover', 75 | gravity: 'top' 76 | }); 77 | 78 | // Since the resulting image will be a square, we don't really need to fetch the metadata in this case. 79 | // The meta data provides both the original and transformed dimensions of the image. 80 | // If you don't know the dimensions of the transform image, you can get them with a request 81 | // like this. The metadataUrl you get from the transform() call. 82 | // const metadata = await fetchMetadata(metadataUrl); 83 | 84 | if (!url) { 85 | return undefined; 86 | } 87 | 88 | const thumb = { 89 | url, 90 | attributes: { 91 | width: IMAGE_SIZE, // Post transform width 92 | height: IMAGE_SIZE // Post transform height 93 | } 94 | }; 95 | 96 | return { ...record.toSerializable(), thumb }; 97 | }) 98 | ) 99 | ); 100 | console.timeEnd('Fetching images transforms'); 101 | 102 | // Find the top 10 tags using Xata's summarize helper 103 | const tags = topTags.summaries.map((tagSummary) => { 104 | const tag = tagSummary.tag; 105 | const serializableTag = pick(tag, ['id', 'name', 'slug']); 106 | return { 107 | ...serializableTag, 108 | imageCount: tagSummary.imageCount 109 | }; 110 | }) as TagWithImageCount[]; 111 | 112 | const readOnly = process.env.READ_ONLY === 'true'; 113 | 114 | return ; 115 | } 116 | -------------------------------------------------------------------------------- /app/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { CacheProvider } from '@chakra-ui/next-js'; 4 | import { ChakraProvider, ColorModeScript, extendTheme } from '@chakra-ui/react'; 5 | import { Global } from '@emotion/react'; 6 | import { GlobalStyle } from '../theme/globalstyles'; 7 | import { default as theme } from '../theme/theme'; 8 | 9 | const customTheme = extendTheme(theme); 10 | 11 | export function Providers({ children }: { children: React.ReactNode }) { 12 | return ( 13 | 14 | 15 | 16 | 17 | {children} 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/tags/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { compact } from 'lodash'; 2 | import { Images, TagWithImageCount } from '~/components/images'; 3 | import { IMAGES_PER_PAGE_COUNT, IMAGE_SIZE } from '~/utils/constants'; 4 | import { getXataClient } from '~/utils/xata'; 5 | 6 | const xata = getXataClient(); 7 | 8 | // Xata provides a summarize helper to get the total number of images for a tag 9 | const getTagImageCount = async (id: string) => { 10 | const summarizeTag = await xata.db['tag-to-image'] 11 | .filter({ 12 | 'tag.id': id 13 | }) 14 | .summarize({ 15 | columns: ['tag'], 16 | summaries: { 17 | totalCount: { count: '*' } 18 | } 19 | }); 20 | 21 | return summarizeTag.summaries[0] ? summarizeTag.summaries[0].totalCount : 0; 22 | }; 23 | 24 | export default async function Page({ 25 | params: { id }, 26 | searchParams 27 | }: { 28 | params: { id: string }; 29 | searchParams: { page: string }; 30 | }) { 31 | const pageNumber = parseInt(searchParams.page) || 1; 32 | 33 | // We use Xata's getPaginated helper to get a paginated list of images matching this tag 34 | const recordsWithTag = await xata.db['tag-to-image'] 35 | .filter({ 36 | 'tag.id': id 37 | }) 38 | .select(['*', 'image.image']) 39 | .getPaginated({ 40 | pagination: { size: IMAGES_PER_PAGE_COUNT, offset: IMAGES_PER_PAGE_COUNT * pageNumber - IMAGES_PER_PAGE_COUNT } 41 | }); 42 | 43 | // We use Xata's transform helper to create a thumbnail for each image 44 | // and apply it to the image object 45 | const imageRecords = compact( 46 | recordsWithTag.records.map((record) => { 47 | if (!record.image?.image) { 48 | return undefined; 49 | } 50 | const { url } = record.image?.image?.transform({ 51 | width: IMAGE_SIZE, 52 | height: IMAGE_SIZE, 53 | format: 'auto', 54 | fit: 'cover', 55 | gravity: 'top' 56 | }); 57 | if (!url) { 58 | return undefined; 59 | } 60 | const thumb = { 61 | url, 62 | attributes: { width: IMAGE_SIZE, height: IMAGE_SIZE } 63 | }; 64 | 65 | // Next JS requires that we return a serialized object 66 | // Xata provides a toSerializable method for this purpose 67 | // 68 | // In the client side code where this is called we map 69 | // it back to the ImageRecord type 70 | return { ...record.image.toSerializable(), thumb }; 71 | }) 72 | ); 73 | 74 | const tagImageCount = await getTagImageCount(id); 75 | 76 | const tag = await xata.db.tag.read(id); 77 | const tagWithCount = { 78 | // Same as above, we use toSerializable to get a plain object for Next.js 79 | ...tag?.toSerializable(), 80 | imageCount: tagImageCount 81 | } as TagWithImageCount; 82 | 83 | const totalNumberOfPages = Math.ceil(tagImageCount / IMAGES_PER_PAGE_COUNT); 84 | 85 | // This page object is needed for building the buttons in the pagination component 86 | const page = { 87 | pageNumber, 88 | hasNextPage: recordsWithTag.hasNextPage(), 89 | hasPreviousPage: pageNumber > 1, 90 | totalNumberOfPages: totalNumberOfPages 91 | }; 92 | 93 | const readOnly = process.env.READ_ONLY === 'true'; 94 | 95 | return ; 96 | } 97 | -------------------------------------------------------------------------------- /components/icons/discord.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, IconProps } from '@chakra-ui/react'; 2 | 3 | export const DiscordIcon: React.FC = ({ css, ...props }) => ( 4 | 5 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /components/icons/github.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, IconProps } from '@chakra-ui/react'; 2 | 3 | export const GitHubIcon: React.FC = ({ css, ...props }) => ( 4 | 5 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /components/icons/twitter.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, IconProps } from '@chakra-ui/react'; 2 | 3 | export const TwitterIcon: React.FC = ({ css, ...props }) => ( 4 | 5 | 6 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /components/icons/xataWordmark.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, IconProps } from '@chakra-ui/react'; 2 | 3 | export const XataWordMarkIcon: React.FC = ({ css, ...props }) => ( 4 | 5 | 9 | 13 | 17 | 21 | 25 | 26 | 34 | 35 | 36 | 37 | 45 | 46 | 47 | 48 | 56 | 57 | 58 | 59 | 67 | 68 | 69 | 70 | 71 | 72 | ); 73 | -------------------------------------------------------------------------------- /components/images/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { Link } from '@chakra-ui/next-js'; 3 | import { Box, Flex, Heading, Select, SimpleGrid, Tag, Text } from '@chakra-ui/react'; 4 | import { JSONData } from '@xata.io/client'; 5 | import { range } from 'lodash'; 6 | import Image from 'next/image'; 7 | import NextLink from 'next/link'; 8 | import { useRouter } from 'next/navigation'; 9 | import { FC } from 'react'; 10 | import { ImageRecord, TagRecord } from '~/utils/xata'; 11 | import { Search } from '../search'; 12 | import { ImageUpload } from './upload'; 13 | 14 | // Because we serialized our data with .toSerializabe() server side, 15 | // we need to cast it back to the original type as JSON Data 16 | // Xata provides JSONData for this purpose 17 | export type ImageRecordWithThumb = JSONData & { 18 | thumb: { 19 | url: string; 20 | attributes: { 21 | width: number; 22 | height: number; 23 | }; 24 | }; 25 | }; 26 | 27 | // A similar strategy is used for tags 28 | export type TagWithImageCount = JSONData & { 29 | imageCount: number; 30 | }; 31 | 32 | export type Page = { 33 | pageNumber: number; 34 | hasNextPage: boolean; 35 | hasPreviousPage: boolean; 36 | totalNumberOfPages: number; 37 | }; 38 | 39 | type ImagesProps = { 40 | images: ImageRecordWithThumb[]; 41 | tags: TagWithImageCount[]; 42 | page: Page; 43 | readOnly: boolean; 44 | }; 45 | 46 | export const Images: FC = ({ images, tags, page, readOnly }) => { 47 | const currentPage = page.pageNumber; 48 | const router = useRouter(); 49 | 50 | // We render the tags in a different way depending on how many there are 51 | const renderTags = (tags: TagWithImageCount[]) => { 52 | if (tags.length === 0) { 53 | return null; 54 | } 55 | 56 | if (tags.length > 1) { 57 | return ( 58 | <> 59 | 60 | All images 61 | 62 | {tags && ( 63 | 64 | {tags.map((tag) => ( 65 | 66 | {tag.name} 67 | 76 | {tag.imageCount} 77 | 78 | 79 | ))} 80 | 81 | )} 82 | 83 | ); 84 | } 85 | 86 | return ( 87 | <> 88 | 89 | {tags[0].imageCount} images tagged with {tags[0].name} 90 | 91 | 92 | « Back to all images 93 | 94 | 95 | ); 96 | }; 97 | 98 | return ( 99 | <> 100 | 101 | 102 | 103 | 104 | {renderTags(tags)} 105 | {images.length === 0 && No images yet added} 106 | 107 | {/* This uses the thumbnails we created server side based off our images */} 108 | {images.map(({ id, name, thumb }) => { 109 | return ( 110 | 111 | 112 | {name 118 | 119 | 120 | ); 121 | })} 122 | 123 | {/* 124 | Server side we created a page object that contains information about the current page, 125 | then find the current page from the router query. 126 | */} 127 | {page.totalNumberOfPages > 1 && ( 128 | 129 | 130 | {page.hasPreviousPage && Previous} 131 | 138 | 139 | {page.hasNextPage && Next} 140 | 141 | 142 | )} 143 | 144 | ); 145 | }; 146 | -------------------------------------------------------------------------------- /components/images/individual.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { Link } from '@chakra-ui/next-js'; 3 | import { Box, BoxProps, Button, Flex, FormControl, FormLabel, Heading, Tag, Text, useToast } from '@chakra-ui/react'; 4 | import { JSONData, transformImage } from '@xata.io/client'; 5 | import { motion } from 'framer-motion'; 6 | import NextImage from 'next/image'; 7 | import NextLink from 'next/link'; 8 | import { useRouter } from 'next/navigation'; 9 | import { FC } from 'react'; 10 | import { ImageRecord, TagRecord } from '~/utils/xata'; 11 | import { Search } from '../search'; 12 | import { ImageUpload } from './upload'; 13 | 14 | // Because we serialized our data with .toSerializabe() server side, 15 | // we need to cast it back to the original type as JSON Data 16 | // Xata provides JSONData for this purpose 17 | interface ImageProps { 18 | image: JSONData; 19 | tags: JSONData[]; 20 | readOnly: boolean; 21 | } 22 | 23 | export const Image: FC = ({ image, tags, readOnly }) => { 24 | const router = useRouter(); 25 | const toast = useToast(); 26 | const handleDelete = async () => { 27 | const response = await fetch(`/api/images/${image.id}`, { method: 'DELETE' }); 28 | if (response.status === 200) { 29 | router.refresh(); 30 | router.push('/'); 31 | toast({ 32 | title: 'Image deleted', 33 | description: `Image ${image.name} has been deleted`, 34 | status: 'success', 35 | duration: 3000, 36 | isClosable: true 37 | }); 38 | } 39 | }; 40 | 41 | if (!image.image) return null; 42 | 43 | // Xata provides a helper to transform images on the client side. It works the same 44 | // as the server side helper, but will perform after the page render so you might want 45 | // to add a transition to account for the slight delay. 46 | // 47 | // A good usage of client side transformation might be to dynamically make 48 | // images sizes based on viewport 49 | const clientSideThumbnailUrl = transformImage(image.image.url, { 50 | width: 1000, 51 | height: 1000, 52 | fit: 'cover', 53 | gravity: 'center', 54 | blur: 100 55 | }); 56 | 57 | // We use framer motion to animate the client side image on page load 58 | type MotionBoxProps = Omit; 59 | const MotionBox = motion(Box); 60 | 61 | return ( 62 | <> 63 | 78 | 79 | 80 | 81 | 82 | 83 | {image.name} 84 | 85 | 86 | « Back to all images 87 | 88 | 89 | 90 | {/* This is the original image */} 91 | 92 | 98 | 99 | 100 | 110 | 111 | Image name 112 | {image.name} 113 | 114 | 115 | Original image URL 116 | 117 | {image.image.url} 118 | 119 | 120 | 121 | Original width 122 | {image.image.attributes?.width} 123 | 124 | 125 | Original height 126 | {image.image.attributes?.height} 127 | 128 | {tags.length > 0 && ( 129 | 130 | Tagged as 131 | 132 | {tags?.map((tag) => ( 133 | 134 | {tag.name} 135 | 136 | ))} 137 | 138 | 139 | )} 140 | {!readOnly && ( 141 | 142 | 145 | 146 | )} 147 | 148 | 149 | 150 | ); 151 | }; 152 | -------------------------------------------------------------------------------- /components/images/upload.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { 3 | Box, 4 | Button, 5 | Flex, 6 | FormControl, 7 | FormLabel, 8 | Input, 9 | Link, 10 | Modal, 11 | ModalBody, 12 | ModalCloseButton, 13 | ModalContent, 14 | ModalFooter, 15 | ModalHeader, 16 | ModalOverlay, 17 | Progress, 18 | Text, 19 | useDisclosure, 20 | useToast 21 | } from '@chakra-ui/react'; 22 | import { useRouter } from 'next/navigation'; 23 | import { FC, FormEvent, useState } from 'react'; 24 | import { GitHubIcon } from '../icons/github'; 25 | 26 | interface ImageUploadProps { 27 | readOnly: boolean; 28 | } 29 | 30 | export const ImageUpload: FC = ({ readOnly }) => { 31 | const { isOpen, onOpen, onClose } = useDisclosure(); 32 | const [name, setName] = useState(''); 33 | const [file, setFile] = useState(null); 34 | const [tags, setTags] = useState(''); 35 | const [isUploading, setIsUploading] = useState(false); 36 | const [message, setMessage] = useState(''); 37 | const router = useRouter(); 38 | const toast = useToast(); 39 | 40 | const handleSubmit = async (e: FormEvent) => { 41 | e.preventDefault(); 42 | 43 | if (!file || !name || !tags) { 44 | setMessage('Name, file and tags are required.'); 45 | return; 46 | } 47 | 48 | // Grab the form data 49 | const formData = new FormData(); 50 | const fileObj = file as File; 51 | formData.append('fileType', fileObj.type); 52 | formData.append('name', name); 53 | formData.append('tags', tags); 54 | 55 | try { 56 | // This route creates new image and tag records in Xata 57 | // If you look at the api route code, you'll see that we're not actually 58 | // uploading the image here. Instead, we're creating a record in the database 59 | // with a temporary, empty image. We do this because we need to generate a 60 | // pre-signed URL for the image upload. 61 | const response = await fetch('/api/images', { 62 | method: 'POST', 63 | body: formData 64 | }); 65 | if (response.status !== 200) { 66 | throw new Error("Couldn't create image record"); 67 | } 68 | 69 | const record = await response.json(); 70 | 71 | // The response include a pre-signed uploadUrl on the record. Below, we then send a file 72 | // directly to Xata on the client side using the PUT request. This lets us upload 73 | // large files that would otherwise exceed the limit for serverless functions on 74 | // services like Vercel. 75 | 76 | if (response.status === 200) { 77 | setIsUploading(true); 78 | try { 79 | setIsUploading(true); 80 | await fetch(record.image.uploadUrl, { method: 'PUT', body: file }); 81 | toast({ 82 | title: 'Image uploaded.', 83 | description: 'Your image was uploaded successfully.', 84 | status: 'success', 85 | duration: 5000, 86 | isClosable: true 87 | }); 88 | setIsUploading(false); 89 | router.push(`/images/${record.id}`); 90 | } catch (error) { 91 | // Delete the record and associated tag 92 | await fetch(`/api/images/${record.id}`, { method: 'DELETE' }); 93 | setIsUploading(false); 94 | throw new Error("Couldn't upload image"); 95 | } 96 | } else { 97 | throw new Error("Couldn't upload image"); 98 | } 99 | } catch (error) { 100 | setMessage('An error occurred while uploading the image.'); 101 | } 102 | }; 103 | 104 | const handleFileChange = (e: any) => { 105 | setFile(e.target.files[0]); 106 | }; 107 | 108 | if (readOnly) { 109 | return ( 110 | 111 | 119 | 120 | This demo set to read only mode.{' '} 121 | Run it locally to explore the 122 | full functionality. 123 | 124 | 125 | ); 126 | } 127 | 128 | return ( 129 | <> 130 | 133 | 134 | 135 | 136 | 137 |
138 | Add an image 139 | 140 | 141 | 142 | 143 | Name 144 | setName(e.target.value)} /> 145 | 146 | 147 | Tags 148 | setTags(e.target.value)} /> 149 | 150 | 151 | Image 152 | 153 | 154 | 155 | {isUploading && } 156 | {message &&
{message}
} 157 |
158 | 159 | 160 | 163 | 166 | 167 | 168 |
169 |
170 | 171 | ); 172 | }; 173 | -------------------------------------------------------------------------------- /components/layout/base.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { Link } from '@chakra-ui/next-js'; 3 | import { Flex, Icon, IconButton, Text, Tooltip, useColorMode } from '@chakra-ui/react'; 4 | import { WeatherMoon20Filled, WeatherSunny20Filled } from '@fluentui/react-icons'; 5 | import NextLink from 'next/link'; 6 | import { FC } from 'react'; 7 | import { DiscordIcon } from '../icons/discord'; 8 | import { GitHubIcon } from '../icons/github'; 9 | import { TwitterIcon } from '../icons/twitter'; 10 | import { XataWordMarkIcon } from '../icons/xataWordmark'; 11 | 12 | interface BaseLayoutProps { 13 | children: React.ReactNode; 14 | } 15 | 16 | export const BaseLayout: FC = ({ children }) => { 17 | const { toggleColorMode, colorMode } = useColorMode(); 18 | const isDark = colorMode === 'dark'; 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | Docs 27 | Examples 28 | 29 | } 33 | onClick={toggleColorMode} 34 | /> 35 | 36 | 37 | 38 | 39 | {children} 40 | 41 | 42 | 43 | Xata © {new Date().getFullYear()} 44 | 45 | 46 | 47 | } 52 | href="https://github.com/xataio" 53 | /> 54 | } 59 | href="https://xata.io/discord" 60 | /> 61 | } 66 | href="https://twitter.com/xata" 67 | /> 68 | 69 | 70 | 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /components/layout/loading.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { Button, Flex } from '@chakra-ui/react'; 3 | import { FC } from 'react'; 4 | import { ImageUpload } from '../images/upload'; 5 | 6 | interface LoadingProps { 7 | readOnly: boolean; 8 | } 9 | 10 | export const HeaderLoading: FC = ({ readOnly }) => { 11 | return ( 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /components/search/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Input, 5 | InputGroup, 6 | InputRightElement, 7 | Modal, 8 | ModalBody, 9 | ModalContent, 10 | ModalOverlay, 11 | Spinner, 12 | useDisclosure 13 | } from '@chakra-ui/react'; 14 | import { debounce, isNil } from 'lodash'; 15 | import { useRouter } from 'next/navigation'; 16 | import { ChangeEvent, FC, useEffect, useRef, useState } from 'react'; 17 | import { SearchResult } from '~/components/search/result'; 18 | 19 | // This component is used to search for images. The majority of this code is for the UI 20 | // and not related to Xata. The only Xata related code is the fetchRecords function which 21 | // is called when the user types in the search box. This function hits an API route 22 | // which uses Xata to search for images. 23 | export const Search: FC = () => { 24 | const { isOpen, onOpen, onClose } = useDisclosure(); 25 | const [focused, setFocused] = useState(); 26 | const router = useRouter(); 27 | const [searchQuery, setSearchQuery] = useState(''); 28 | const [searchResults, setSearchResults] = useState([]); 29 | const [isLoadingSearch, setIsLoadingSearch] = useState(false); 30 | 31 | const resultsRef = useRef(null); 32 | const handleSearchChange = (event: ChangeEvent) => { 33 | setSearchQuery(event.target.value); 34 | }; 35 | 36 | // This effect is used to scroll the focused result into view 37 | useEffect(() => { 38 | if (!focused || !resultsRef.current) return; 39 | 40 | // @ts-ignore-next-line 41 | const resultElement = resultsRef.current.querySelector('[data-id="' + focused.record.id + '"]'); 42 | 43 | if (!resultElement) return; 44 | 45 | resultElement.scrollIntoView({ block: 'center' }); 46 | }, [focused]); 47 | 48 | // This effect is used to fetch search results when the user types in the search box 49 | useEffect(() => { 50 | const fetchRecords = async () => { 51 | if (isNil(searchQuery) || searchQuery === '') { 52 | setSearchResults([]); 53 | return; 54 | } 55 | setIsLoadingSearch(true); 56 | // Check the API route to see how we use Xata to search for images 57 | const response = await fetch(`/api/images/search?query=${searchQuery}`); 58 | const results = await response.json(); 59 | setFocused(results[0]); 60 | 61 | setSearchResults(results); 62 | setIsLoadingSearch(false); 63 | }; 64 | 65 | void fetchRecords(); 66 | }, [searchQuery]); 67 | 68 | const debounceOnChange = debounce(handleSearchChange, 250); 69 | 70 | // This effect is used to handle keyboard events while the search modal is open 71 | useEffect(() => { 72 | const onKeyDown = (e: KeyboardEvent) => { 73 | if (e.key === 'ArrowDown') { 74 | e.preventDefault(); 75 | setFocused((prevFocused: any) => { 76 | const index = searchResults.indexOf(prevFocused); 77 | 78 | if (index === -1 || index === searchResults.length - 1) return prevFocused; 79 | 80 | return searchResults[index + 1]; 81 | }); 82 | } 83 | 84 | if (e.key === 'ArrowUp') { 85 | e.preventDefault(); 86 | setFocused((prevFocused: any) => { 87 | const index = searchResults.indexOf(prevFocused); 88 | 89 | if (index === -1 || index === 0) return prevFocused; 90 | 91 | return searchResults[index - 1]; 92 | }); 93 | } 94 | 95 | if (e.key === 'Enter' && focused) { 96 | e.preventDefault(); 97 | // @ts-ignore-next-line 98 | const slug = `/${focused.table}s/${focused.record.id}`; 99 | void router.push(slug); 100 | setSearchQuery(''); 101 | onClose(); 102 | } 103 | }; 104 | 105 | document.addEventListener('keydown', onKeyDown); 106 | 107 | return () => document.removeEventListener('keydown', onKeyDown); 108 | }, [onClose, searchResults, router, focused, isOpen]); 109 | 110 | // Clear the search results on close 111 | const handleClose = () => { 112 | setSearchQuery(''); 113 | onClose(); 114 | }; 115 | 116 | return ( 117 | <> 118 | 119 | 120 | 121 | 122 | 131 | 132 | 133 | 147 | {isLoadingSearch && ( 148 | 149 | 150 | 151 | )} 152 | 153 | 161 | {searchResults.map((result) => ( 162 | 169 | ))} 170 | 171 | 172 | 173 | 174 | 175 | ); 176 | }; 177 | -------------------------------------------------------------------------------- /components/search/result.tsx: -------------------------------------------------------------------------------- 1 | import { Badge, Box, Flex, Icon, LinkBox, LinkOverlay, Text, VStack } from '@chakra-ui/react'; 2 | import { ChevronRight16Filled } from '@fluentui/react-icons'; 3 | import NextLink from 'next/link'; 4 | import { FC } from 'react'; 5 | 6 | export interface SearchResultProps { 7 | result: any; 8 | onClick: () => void; 9 | isFocused: boolean; 10 | } 11 | 12 | export const SearchResult: FC = ({ result, onClick, isFocused }) => { 13 | let resultTitle; 14 | if (result.record.xata.highlight.title !== undefined) { 15 | resultTitle = ; 16 | } else { 17 | resultTitle = result.record.name; 18 | } 19 | 20 | const slug = `/${result.table}s/${result.record.id}`; 21 | 22 | const badgeText = result.table; 23 | const badgeColor = result.table === 'tag' ? 'green' : 'blue'; 24 | 25 | return ( 26 | 44 | 45 | 46 | 47 | 48 | {isFocused ? ( 49 | 50 | ) : ( 51 | 52 | )} 53 | 54 | 55 | 63 | {badgeText} 64 | 65 | 75 | {resultTitle} 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const xataDomains = [ 2 | 'us-east-1.storage.xata.sh', 3 | 'us-west-2.storage.xata.sh', 4 | 'eu-west-1.storage.xata.sh', 5 | 'eu-central-1.storage.xata.sh', 6 | 'ap-southeast-2.storage.xata.sh', 7 | 'us-east-1.storage.staging-xata.dev', 8 | 'us-west-2.storage.staging-xata.dev', 9 | 'eu-west-1.storage.staging-xata.dev', 10 | 'eu-central-1.storage.staging-xata.dev', 11 | 'ap-southeast-2.storage.staging-xata.dev' 12 | ]; 13 | 14 | const xataDomainsRemote = xataDomains.map((domain) => { 15 | return { 16 | protocol: 'https', 17 | hostname: domain, 18 | port: '' 19 | }; 20 | }); 21 | 22 | /** @type {import('next').NextConfig} */ 23 | const nextConfig = { 24 | images: { 25 | remotePatterns: xataDomainsRemote 26 | } 27 | }; 28 | 29 | module.exports = nextConfig; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xata-image-gallery", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "chakra-types-gen": "chakra-cli tokens ./theme/theme.ts", 11 | "postinstall": "pnpm run chakra-types-gen", 12 | "prepare": "husky", 13 | "seed": "node scripts/seed.mjs", 14 | "cleanup": "node scripts/cleanup.mjs", 15 | "bootstrap": "node scripts/bootstrap.mjs", 16 | "xata:one-click": "pnpm install && node scripts/cleanup.mjs --force && node scripts/one-click.mjs && node scripts/seed.mjs" 17 | }, 18 | "dependencies": { 19 | "@chakra-ui/next-js": "^2.2.0", 20 | "@chakra-ui/react": "^2.8.2", 21 | "@chakra-ui/theme": "^3.3.1", 22 | "@chakra-ui/theme-tools": "^2.1.2", 23 | "@emotion/react": "^11.11.4", 24 | "@emotion/styled": "^11.11.5", 25 | "@fluentui/react-icons": "^2.0.237", 26 | "@xata.io/client": "^0.29.4", 27 | "chroma-js": "^2.4.2", 28 | "dotenv": "^16.4.5", 29 | "lodash": "^4.17.21", 30 | "next": "14.2.3", 31 | "prettier": "^3.2.5", 32 | "react": "18.3.1", 33 | "react-dom": "18.3.1", 34 | "slugify": "^1.6.6", 35 | "typescript": "5.4.5", 36 | "uuid": "^9.0.1" 37 | }, 38 | "lint-staged": { 39 | "*.{js,ts,tsx}": [ 40 | "prettier --config=.prettierrc.precommit.js --write", 41 | "eslint --cache --fix" 42 | ], 43 | "*.{css,json,md,yml,yaml}": [ 44 | "prettier --write" 45 | ] 46 | }, 47 | "engines": { 48 | "node": ">=18.12.1 <19", 49 | "pnpm": "^8.6.5" 50 | }, 51 | "devDependencies": { 52 | "@chakra-ui/cli": "^2.4.1", 53 | "@chakra-ui/styled-system": "^2.9.2", 54 | "@types/chroma-js": "^2.4.4", 55 | "@types/lodash": "^4.17.0", 56 | "@types/node": "20.12.7", 57 | "@types/react": "18.3.1", 58 | "@types/react-dom": "18.3.0", 59 | "@types/uuid": "^9.0.8", 60 | "@typescript-eslint/eslint-plugin": "^7.7.1", 61 | "@typescript-eslint/parser": "^7.7.1", 62 | "eslint": "=8.56.0", 63 | "eslint-config-next": "14.2.3", 64 | "eslint-config-prettier": "^9.1.0", 65 | "eslint-plugin-check-file": "^2.8.0", 66 | "eslint-plugin-import": "^2.29.1", 67 | "eslint-plugin-jsx-a11y": "^6.8.0", 68 | "eslint-plugin-prettier": "^5.1.3", 69 | "eslint-plugin-react": "^7.34.1", 70 | "framer-motion": "^11.1.7", 71 | "husky": "^9.0.11", 72 | "lint-staged": "^15.2.2", 73 | "prettier-plugin-organize-imports": "^3.2.4" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sample-data/butterfly_blue_morpho.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/sample-data/butterfly_blue_morpho.jpg -------------------------------------------------------------------------------- /sample-data/butterfly_chilling.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/sample-data/butterfly_chilling.jpg -------------------------------------------------------------------------------- /sample-data/butterfly_ghost.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/sample-data/butterfly_ghost.jpg -------------------------------------------------------------------------------- /sample-data/butterfly_hanging.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/sample-data/butterfly_hanging.jpg -------------------------------------------------------------------------------- /sample-data/butterfly_having_lunch.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/sample-data/butterfly_having_lunch.jpg -------------------------------------------------------------------------------- /sample-data/butterfly_munching.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/sample-data/butterfly_munching.jpg -------------------------------------------------------------------------------- /sample-data/butterfly_on_another_red_flower.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/sample-data/butterfly_on_another_red_flower.jpg -------------------------------------------------------------------------------- /sample-data/butterfly_on_finger.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/sample-data/butterfly_on_finger.jpg -------------------------------------------------------------------------------- /sample-data/butterfly_on_green_plant.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/sample-data/butterfly_on_green_plant.jpg -------------------------------------------------------------------------------- /sample-data/butterfly_on_ground.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/sample-data/butterfly_on_ground.jpg -------------------------------------------------------------------------------- /sample-data/butterfly_on_leaf.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/sample-data/butterfly_on_leaf.jpg -------------------------------------------------------------------------------- /sample-data/butterfly_on_pink_flower.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/sample-data/butterfly_on_pink_flower.jpg -------------------------------------------------------------------------------- /sample-data/butterfly_on_red_flower.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/sample-data/butterfly_on_red_flower.jpg -------------------------------------------------------------------------------- /sample-data/butterfly_on_rocks.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/sample-data/butterfly_on_rocks.jpg -------------------------------------------------------------------------------- /sample-data/butterfly_polka_dot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/sample-data/butterfly_polka_dot.jpg -------------------------------------------------------------------------------- /sample-data/butterfly_sleeping.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xataio/sample-nextjs-chakra-gallery-app/b62821ba214d3eb7f0556bb1520c3d1d91862d84/sample-data/butterfly_sleeping.jpg -------------------------------------------------------------------------------- /schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "tables": [ 3 | { 4 | "name": "tag", 5 | "columns": [ 6 | { 7 | "name": "name", 8 | "type": "string", 9 | "notNull": true, 10 | "defaultValue": "gallery" 11 | } 12 | ], 13 | "revLinks": [ 14 | { 15 | "column": "tag", 16 | "table": "tag-to-image" 17 | } 18 | ] 19 | }, 20 | { 21 | "name": "image", 22 | "columns": [ 23 | { 24 | "name": "name", 25 | "type": "string", 26 | "notNull": true, 27 | "defaultValue": "Image" 28 | }, 29 | { 30 | "name": "image", 31 | "type": "file", 32 | "file": { 33 | "defaultPublicAccess": true 34 | } 35 | } 36 | ], 37 | "revLinks": [ 38 | { 39 | "column": "image", 40 | "table": "tag-to-image" 41 | } 42 | ] 43 | }, 44 | { 45 | "name": "tag-to-image", 46 | "columns": [ 47 | { 48 | "name": "image", 49 | "type": "link", 50 | "link": { 51 | "table": "image" 52 | } 53 | }, 54 | { 55 | "name": "tag", 56 | "type": "link", 57 | "link": { 58 | "table": "tag" 59 | } 60 | } 61 | ] 62 | } 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /scripts/bootstrap.mjs: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | 3 | const args = process.argv.slice(2); 4 | 5 | function getArgumentValue(key) { 6 | const index = args.indexOf(key); 7 | if (index !== -1 && index < args.length - 1) { 8 | return args[index + 1]; 9 | } 10 | return null; 11 | } 12 | 13 | const dbValue = getArgumentValue('--db'); 14 | const profileValue = getArgumentValue('--profile'); 15 | 16 | let cmd = 'node scripts/cleanup.mjs --force && xata init --schema schema.json --codegen=utils/xata.ts'; 17 | if (dbValue) { 18 | cmd += ` --db ${dbValue}`; 19 | } 20 | if (profileValue) { 21 | cmd += ` --profile ${profileValue}`; 22 | } 23 | cmd += ' && node scripts/seed.mjs'; 24 | 25 | execSync(cmd, { stdio: 'inherit' }); 26 | -------------------------------------------------------------------------------- /scripts/cleanup.mjs: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | import { exec } from 'node:child_process'; 3 | 4 | if (process.argv.length < 3 || process.argv[2] !== '--force') { 5 | console.log(`❯ ☢️ This deletes all Xata generated files, including the schema history!`); 6 | console.log(`❯ ⚠️ Please run this command with the --force flag to continue.`); 7 | process.exit(0); 8 | } 9 | 10 | try { 11 | exec(`rm -rf ./.xata ./utils/xata.ts ./.xatarc`); 12 | } catch { 13 | console.warn('Cleanup gone wrong.'); 14 | } 15 | 16 | console.log(`❯ ✅ Cleanup complete.`); 17 | -------------------------------------------------------------------------------- /scripts/one-click.mjs: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | import dotenv from 'dotenv'; 3 | import { exec } from 'node:child_process'; 4 | 5 | dotenv.config({ 6 | path: '.env.local' 7 | }); 8 | 9 | try { 10 | console.log(`❯ Setting up database at ${process.env.XATA_DATABASE_URL}`); 11 | 12 | exec( 13 | `pnpm -s dlx @xata.io/cli@latest init --schema=schema.json --codegen=utils/xata.ts --db=${process.env.XATA_DATABASE_URL} --yes`, 14 | (_error, stdout, stderr) => { 15 | console.log('❯ Running pnpm dlx'); 16 | 17 | if (stderr) { 18 | console.error(`Finished with issues: \n${stderr}`); 19 | return; 20 | } 21 | console.log(stdout); 22 | } 23 | ); 24 | } catch { 25 | console.warn('Setup gone wrong.'); 26 | } 27 | -------------------------------------------------------------------------------- /scripts/seed.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { buildClient } from '@xata.io/client'; 3 | import dotenv from 'dotenv'; 4 | import { promises as fs } from 'fs'; 5 | 6 | dotenv.config({ 7 | path: '.env.local' 8 | }); 9 | 10 | const BUTTERFLIES = [ 11 | { 12 | id: 'rec_cjjcts0t25vi657kar7g', 13 | name: 'Butterfly on pink flower', 14 | image: 'butterfly_on_pink_flower.jpg', 15 | tags: ['brown', 'orange', 'blue'] 16 | }, 17 | { 18 | id: 'rec_cjjcudcid69somlqt0r0', 19 | name: 'Butterfly on rocks', 20 | image: 'butterfly_on_rocks.jpg', 21 | tags: ['black', 'orange'] 22 | }, 23 | { 24 | id: 'rec_cjjculkid69somlqt0s0', 25 | name: 'Butterfly on red flower', 26 | image: 'butterfly_on_red_flower.jpg', 27 | tags: ['black', 'white'] 28 | }, 29 | { 30 | id: 'rec_cjjcv4a0dc6heui1ju2g', 31 | name: 'Butterfly on green plant', 32 | image: 'butterfly_on_green_plant.jpg', 33 | tags: ['red', 'white', 'black'] 34 | }, 35 | { 36 | id: 'rec_cjjcvra0dc6heui1ju4g', 37 | name: 'Ghost-looking butterfly', 38 | image: 'butterfly_ghost.jpg', 39 | tags: ['black', 'white'] 40 | }, 41 | { 42 | id: 'rec_cjjd07sid69somlqt10g', 43 | name: 'Butterfly on leaf', 44 | image: 'butterfly_on_leaf.jpg', 45 | tags: ['orange', 'black', 'white'] 46 | }, 47 | { 48 | id: 'rec_cjjd0t20dc6heui1ju50', 49 | name: 'Butterfly on red flower', 50 | image: 'butterfly_on_another_red_flower.jpg', 51 | tags: ['white', 'black'] 52 | }, 53 | { 54 | id: 'rec_cjjd1eot25vi657karbg', 55 | name: 'Butterfly having lunch', 56 | image: 'butterfly_having_lunch.jpg', 57 | tags: ['beige', 'black'] 58 | }, 59 | { 60 | id: 'rec_cjjd244id69somlqt2a0', 61 | name: 'Polka dot butterfly', 62 | image: 'butterfly_polka_dot.jpg', 63 | tags: ['blue', 'gray'] 64 | }, 65 | { 66 | id: 'rec_cjjd2gcid69somlqt2ag', 67 | name: 'Butterfly on pink flower', 68 | image: 'butterfly_on_pink_flower.jpg', 69 | tags: ['white', 'black'] 70 | }, 71 | { 72 | id: 'rec_cjjd30cid69somlqt2b0', 73 | name: 'Butterfly chilling', 74 | image: 'butterfly_chilling.jpg', 75 | tags: ['white', 'yellow', 'orange'] 76 | }, 77 | { 78 | id: 'rec_cjjd3gkid69somlqt2bg', 79 | name: 'Buttefly on the ground', 80 | image: 'butterfly_on_ground.jpg', 81 | tags: ['red', 'white', 'black'] 82 | }, 83 | { 84 | id: 'rec_cjjd3sot25vi657karc0', 85 | name: 'Butterfly on finger', 86 | image: 'butterfly_on_finger.jpg', 87 | tags: ['blue', 'green', 'orange'] 88 | }, 89 | { 90 | id: 'rec_cjjd4egt25vi657karcg', 91 | name: 'Butterfly munching on a flower', 92 | image: 'butterfly_munching.jpg', 93 | tags: ['orange', 'black', 'white'] 94 | }, 95 | { 96 | id: 'rec_cjjd4t4id69somlqt2d0', 97 | name: 'Butterfly hanging on a limb', 98 | image: 'butterfly_hanging.jpg', 99 | tags: ['red', 'yellow', 'black', 'green'] 100 | }, 101 | { 102 | id: 'rec_cjjn1a3qdm7j0snh2i90', 103 | name: 'Sleepy butterfly', 104 | image: 'butterfly_sleeping.jpg', 105 | tags: ['black', 'brown', 'orange'] 106 | }, 107 | { 108 | id: 'rec_ckimbmrmefk3dnjk07n0', 109 | name: 'Blue morpho', 110 | image: 'butterfly_blue_morpho.jpg', 111 | tags: ['blue'] 112 | } 113 | ]; 114 | 115 | dotenv.config(); // Load the default .env file 116 | 117 | class XataClient extends buildClient() { 118 | /** 119 | * 120 | * @param {{}} options 121 | */ 122 | constructor(options) { 123 | super({ 124 | ...options 125 | }); 126 | } 127 | } 128 | 129 | /** 130 | * @param {string} filePath 131 | * @return {Promise} 132 | */ 133 | async function readDatabaseURL(filePath) { 134 | try { 135 | const json = JSON.parse(await fs.readFile(filePath, 'utf-8')); 136 | return json.databaseURL; 137 | } catch (error) { 138 | console.error('Error reading file:', error); 139 | throw error; 140 | } 141 | } 142 | 143 | /** 144 | * @return {Promise} 145 | */ 146 | async function getXataClient() { 147 | // Prefer the env var, but fallback to the .xatarc file 148 | const dbURL = process.env.XATA_DATABASE_URL || (await readDatabaseURL('./.xatarc')); 149 | console.log(`❯ Connecting to database: ${dbURL}`); 150 | return new XataClient({ 151 | databaseURL: dbURL, 152 | apiKey: process.env.XATA_API_KEY, 153 | branch: process.env.XATA_BRANCH || 'main' 154 | }); 155 | } 156 | 157 | /** 158 | * @param {XataClient} xata 159 | * @return {Promise} 160 | */ 161 | async function isDBpopulated(xata) { 162 | const { summaries } = await xata.db.image.summarize({ 163 | summaries: { 164 | totalCount: { 165 | count: '*' 166 | } 167 | } 168 | }); 169 | if (summaries[0].totalCount > 0) { 170 | return true; 171 | } 172 | 173 | return false; 174 | } 175 | 176 | /** 177 | * @param {string} filePath 178 | * @return {Promise} 179 | */ 180 | async function encodeImageToBase64(filePath) { 181 | try { 182 | const fileBuffer = await fs.readFile(filePath); 183 | return fileBuffer.toString('base64'); 184 | } catch (error) { 185 | console.error('Error reading file:', error); 186 | throw error; 187 | } 188 | } 189 | 190 | /** 191 | * @param {XataClient} xata 192 | */ 193 | async function insertMockData(xata) { 194 | //const TAGS = ['orange', 'brown', 'blue', 'white', 'gray', 'green', 'red', 'yellow', 'beige']; 195 | const allTags = BUTTERFLIES.flatMap((butterfly) => butterfly.tags); 196 | const uniqueTags = [...new Set(allTags)]; 197 | const tags = uniqueTags.map((name) => ({ 198 | id: name, 199 | name 200 | })); 201 | await xata.db.tag.create(tags); 202 | 203 | const images = await Promise.all( 204 | BUTTERFLIES.map(async (item) => ({ 205 | id: item.id, 206 | name: item.name, 207 | image: { 208 | name: item.image, 209 | enablePublicUrl: true, // XXX: ideally this would be set at the column level 210 | mediaType: 'image/jpeg', 211 | base64Content: await encodeImageToBase64('./sample-data/' + item.image) 212 | } 213 | })) 214 | ); 215 | await xata.db.image.create(images); 216 | 217 | // junction table items 218 | /** 219 | * @type {{ image: string; tag: string; }[]} 220 | */ 221 | const tagToImage = []; 222 | BUTTERFLIES.forEach((butterfly) => { 223 | butterfly.tags.forEach((tag) => { 224 | tagToImage.push({ image: butterfly.id, tag: tag }); 225 | }); 226 | }); 227 | await xata.db['tag-to-image'].create(tagToImage); 228 | } 229 | 230 | export async function seed() { 231 | const xata = await getXataClient(); 232 | if (await isDBpopulated(xata)) { 233 | console.warn('Database is not empty. Skip seeding...'); 234 | return; 235 | } 236 | 237 | try { 238 | await insertMockData(xata); 239 | 240 | console.log(`🎉 Seed data successfully inserted!`); 241 | 242 | return 'success'; 243 | } catch (err) { 244 | console.error('Error: ', err); 245 | } 246 | } 247 | 248 | try { 249 | void seed(); 250 | } catch { 251 | console.warn('Seeding gone wrong.'); 252 | } 253 | -------------------------------------------------------------------------------- /theme/globalstyles.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | 3 | export const GlobalStyle = css` 4 | html { 5 | font-family: 'Inter', sans-serif; 6 | scroll-behavior: smooth; 7 | } 8 | body { 9 | max-width: 100vw; 10 | } 11 | @supports (font-variation-settings: normal) { 12 | html { 13 | font-family: 'Inter var', sans-serif; 14 | } 15 | } 16 | @font-face { 17 | font-family: 'Inter var'; 18 | font-weight: 100 900; 19 | font-display: swap; 20 | font-style: normal; 21 | font-named-instance: 'Regular'; 22 | src: url('/Inter-roman.var.woff2?v=3.19') format('woff2'); 23 | } 24 | @font-face { 25 | font-family: 'Inter var'; 26 | font-weight: 100 900; 27 | font-display: swap; 28 | font-style: italic; 29 | font-named-instance: 'Italic'; 30 | src: url('/Inter-italic.var.woff2?v=3.19') format('woff2'); 31 | } 32 | 33 | @font-face { 34 | font-family: 'JetBrainsMono'; 35 | src: url('/JetBrainsMono-Medium.woff2') format('woff'); 36 | font-weight: normal; 37 | font-style: normal; 38 | font-display: swap; 39 | } 40 | 41 | div[data-rehype-pretty-code-fragment] { 42 | width: 100%; 43 | max-width: 800px; 44 | margin: auto; 45 | margin-top: 0; 46 | overflow: hidden; 47 | border-radius: 0.5rem; 48 | 49 | &:not(:last-child) { 50 | margin-bottom: 1.5rem; 51 | } 52 | pre > code { 53 | display: grid; 54 | background: var(--chakra-colors-codeBg); 55 | padding: 1rem 0; 56 | font-size: 0.9rem; 57 | overflow: auto; 58 | font-family: 'JetBrainsMono', monospace; 59 | max-height: 100vh; 60 | overflow-y: auto; 61 | } 62 | 63 | div[data-rehype-pretty-code-title] { 64 | background: var(--chakra-colors-codeHeaderBg); 65 | border-top-right-radius: var(--chakra-radii-md); 66 | border-top-left-radius: var(--chakra-radii-md); 67 | color: var(--chakra-colors-gray-50); 68 | font-size: 0.8rem; 69 | padding: 0.5rem 1rem; 70 | } 71 | 72 | div[data-rehype-pretty-code-title] + pre > code { 73 | border-top-left-radius: 0; 74 | border-top-right-radius: 0; 75 | } 76 | 77 | [data-highlighted-chars-wrapper] { 78 | background: var(--chakra-colors-gray-500); 79 | color: var(--chakra-colors-text) !important; 80 | > * { 81 | color: var(--chakra-colors-text) !important; 82 | } 83 | } 84 | [data-highlighted-line] { 85 | background: var(--chakra-colors-whiteAlpha-100); 86 | border-left: 2px solid var(--chakra-colors-gray-400) !important; 87 | } 88 | 89 | code [data-line] { 90 | padding: 0 1rem; 91 | } 92 | 93 | code[data-line-numbers] [data-line] { 94 | padding-left: 0; 95 | } 96 | code[data-line-numbers] > [data-line]::before { 97 | counter-increment: line; 98 | content: counter(line); 99 | 100 | /* Other styling */ 101 | display: inline-block; 102 | width: 1rem; 103 | margin-right: 1rem; 104 | text-align: right; 105 | color: var(--chakra-colors-gray-500); 106 | } 107 | 108 | code[data-line-numbers-max-digits='2'] > [data-line]::before { 109 | width: 2rem; 110 | } 111 | 112 | code[data-line-numbers-max-digits='3'] > [data-line]::before { 113 | width: 3rem; 114 | } 115 | } 116 | 117 | .chakra-ui-light .snippet--hasTabs, 118 | .chakra-ui-light div[data-rehype-pretty-code-fragment] { 119 | box-shadow: 120 | 0 0 0 8px var(--chakra-colors-blackAlpha-100), 121 | 0 0 1px var(--chakra-colors-stroke); 122 | } 123 | 124 | .chakra-ui-dark .snippet--hasTabs, 125 | .chakra-ui-dark div[data-rehype-pretty-code-fragment] { 126 | box-shadow: 127 | 0 0 0 8px var(--chakra-colors-whiteAlpha-50), 128 | 0 0 1px var(--chakra-colors-gray-500); 129 | } 130 | 131 | // snippet with tabs need some adjustments 132 | .snippet--hasTabs { 133 | max-width: 800px; 134 | } 135 | .snippet--hasTabs div[data-rehype-pretty-code-fragment] { 136 | border-radius: 0; 137 | box-shadow: none; 138 | } 139 | 140 | h1, 141 | H2, 142 | H3, 143 | H4, 144 | H5, 145 | H6 { 146 | code { 147 | font-size: inherit !important; 148 | background: var(--chakra-colors-blackAlpha-500); 149 | } 150 | } 151 | 152 | .tabSwiper { 153 | padding: 8px 40px; 154 | position: relative; 155 | max-width: 100%; 156 | 157 | .swiper-button-prev, 158 | .swiper-button-next { 159 | width: 36px; 160 | height: 36px; 161 | border-radius: 50%; 162 | top: 32px; 163 | box-shadow: var(--chakra-shadows-md); 164 | color: var(--chakra-colors-primary); 165 | text-decoration: none; 166 | cursor: pointer; 167 | } 168 | 169 | .swiper-button-prev::after, 170 | .swiper-button-next::after { 171 | font-size: 1.2rem; 172 | font-weight: bold; 173 | } 174 | 175 | .swiper-button-prev { 176 | left: 0; 177 | background: linear-gradient(to right, var(--chakra-colors-gray-700), transparent); 178 | } 179 | 180 | .swiper-button-next { 181 | right: 0; 182 | background: linear-gradient(to left, var(--chakra-colors-gray-700), transparent); 183 | } 184 | 185 | .swiper-slide { 186 | width: auto; 187 | } 188 | } 189 | 190 | .tabSwiper.tabSwiper--isSnippet { 191 | .swiper-button-prev { 192 | left: 0; 193 | top: 26px; 194 | background: linear-gradient(to right, var(--chakra-colors-gray-600), transparent); 195 | } 196 | 197 | .swiper-button-next { 198 | right: 0; 199 | top: 26px; 200 | background: linear-gradient(to left, var(--chakra-colors-gray-600), transparent); 201 | } 202 | } 203 | 204 | .chakra-ui-light .tabSwiper { 205 | .swiper-button-prev, 206 | .swiper-button-next { 207 | background: var(--chakra-colors-bg); 208 | } 209 | } 210 | 211 | .chakra-ui-dark .tabSwiper { 212 | .swiper-button-prev, 213 | .swiper-button-next { 214 | background: var(--chakra-colors-bg); 215 | } 216 | } 217 | 218 | /* clears the ‘X’ from Internet Explorer */ 219 | input[type='search']::-ms-clear { 220 | display: none; 221 | width: 0; 222 | height: 0; 223 | } 224 | input[type='search']::-ms-reveal { 225 | display: none; 226 | width: 0; 227 | height: 0; 228 | } 229 | /* clears the ‘X’ from Chrome/Safari */ 230 | input[type='search']::-webkit-search-decoration, 231 | input[type='search']::-webkit-search-cancel-button, 232 | input[type='search']::-webkit-search-results-button, 233 | input[type='search']::-webkit-search-results-decoration { 234 | display: none; 235 | } 236 | `; 237 | -------------------------------------------------------------------------------- /theme/theme.ts: -------------------------------------------------------------------------------- 1 | import { ComponentStyleConfig, theme as chakraTheme, extendTheme } from '@chakra-ui/react'; 2 | import { GlobalStyleProps, StyleFunctionProps } from '@chakra-ui/theme-tools'; 3 | 4 | import { breakpoints, colors, fonts, semanticTokens, shadows } from './tokens'; 5 | 6 | const inputStyles = (props: GlobalStyleProps) => ({ 7 | borderRadius: 'base', 8 | bg: 'inputBg', 9 | borderColor: 'inputBorder', 10 | _placeholder: { 11 | color: props.colorMode === 'light' ? `gray.400` : `contrastMedium` 12 | }, 13 | _focusVisible: { 14 | outline: 'none', 15 | boxShadow: `inset var(--chakra-colors-text) 0 0 0 0.07rem !important`, 16 | borderColor: 'text' 17 | }, 18 | _hover: { 19 | borderColor: 'contrastMedium' 20 | }, 21 | _disabled: { 22 | color: 'textSubtle', 23 | bg: 'contrastLow', 24 | borderColor: 'stroke', 25 | opacity: 1 26 | } 27 | }); 28 | 29 | export type Statuses = 'info' | 'active' | 'attention' | 'warning' | 'default' | 'danger' | 'success'; 30 | 31 | const Link: ComponentStyleConfig = { 32 | baseStyle: { 33 | color: 'textPrimary', 34 | _hover: { 35 | textDecoration: `underline` 36 | } 37 | } 38 | }; 39 | 40 | const variantSubdued = (props: StyleFunctionProps) => { 41 | const { colorScheme: c } = props; 42 | return { 43 | color: 'text', 44 | background: props.colorMode === 'light' ? `${c}.50` : `${c}.800`, 45 | _hover: { 46 | background: props.colorMode === 'light' ? `${c}.100` : `${c}.700` 47 | } 48 | }; 49 | }; 50 | 51 | const variantHeadingGradient = (props: StyleFunctionProps) => { 52 | const isDarkMode = props.colorMode === 'dark'; 53 | const gradient = isDarkMode 54 | ? 'linear-gradient(87.55deg, var(--chakra-colors-gray-50) 33.91%, var(--chakra-colors-pink-50) 65.85%)' 55 | : 'linear-gradient(87.55deg, var(--chakra-colors-gray-800) 33.91%, var(--chakra-colors-purple-700) 65.85%)'; 56 | 57 | return { 58 | textShadow: 'lg', 59 | // prevent text from getting cut off 60 | paddingBlock: 2, 61 | '@supports (background-clip: text) or (-webkit-background-clip: text)': { 62 | background: gradient, 63 | color: 'transparent', 64 | backgroundClip: 'text' 65 | } 66 | }; 67 | }; 68 | 69 | // eslint-disable-next-line import/no-default-export 70 | export default extendTheme({ 71 | config: { 72 | // System sets initial value. 73 | initialColorMode: 'system', 74 | // App color mode is detached from system color mode changes. 75 | useSystemColorMode: false 76 | }, 77 | styles: { 78 | global: { 79 | // This fixes a bug where Stripe's 3DS prompt was broken on darkmode. 80 | 'iframe[src*="stripe.com"]': { 81 | colorScheme: 'light' 82 | }, 83 | body: { 84 | bg: 'bg', 85 | // default outline style 86 | '*:focus-visible': { 87 | outlineWidth: '2px', 88 | outlineStyle: 'solid', 89 | outlineColor: 'text', 90 | boxShadow: 'none !important' 91 | }, 92 | // main navbar outline should always be a white border for increased visiblity against the various navbar background colors 93 | '#main-navbar *:focus-visible': { 94 | outlineColor: 'white' 95 | }, 96 | // A selector with more specificity to override the behavior of the above selector. 97 | '#main-navbar .default-outline *:focus-visible': { 98 | outlineColor: 'text' 99 | }, 100 | // Bad hack till we move to MDX 101 | '.docs-card-group': { 102 | display: 'grid', 103 | gridTemplateColumns: 'repeat(2, 1fr)', 104 | gap: 8, 105 | mb: 8, 106 | minH: 36, 107 | gridAutoRows: '1fr', 108 | '.chakra-link': { 109 | flexGrow: 1, 110 | display: 'block', 111 | bg: 'contrastLowest', 112 | border: 'solid 1px', 113 | borderColor: 'stroke', 114 | borderRadius: 'md', 115 | padding: 4, 116 | textDecoration: 'none', 117 | ':hover': { 118 | bg: 'contrastLow', 119 | span: { 120 | textDecoration: 'underline' 121 | } 122 | }, 123 | span: { 124 | fontWeight: 'bold' 125 | }, 126 | p: { 127 | color: 'textSubtle' 128 | } 129 | } 130 | } 131 | } 132 | } 133 | }, 134 | colors, 135 | fonts, 136 | // ...borderRadius, 137 | shadows, 138 | semanticTokens, 139 | breakpoints, 140 | components: { 141 | Link, 142 | Input: { 143 | defaultProps: { 144 | ...chakraTheme.components.Input.defaultProps, 145 | size: 'sm' 146 | }, 147 | variants: { 148 | outline: (props: GlobalStyleProps) => ({ 149 | field: inputStyles(props) 150 | }) 151 | } 152 | }, 153 | NumberInput: { 154 | defaultProps: { 155 | ...chakraTheme.components.Input.defaultProps, 156 | size: 'sm' 157 | }, 158 | variants: { 159 | outline: (props: GlobalStyleProps) => ({ 160 | field: inputStyles(props) 161 | }) 162 | } 163 | }, 164 | Textarea: { 165 | defaultProps: { 166 | ...chakraTheme.components.Textarea.defaultProps, 167 | size: 'sm' 168 | }, 169 | variants: { 170 | outline: (props: GlobalStyleProps) => inputStyles(props) 171 | } 172 | }, 173 | FormLabel: { 174 | baseStyle: { 175 | color: 'textSubtle', 176 | fontSize: '12px', 177 | fontWeight: 700 178 | } 179 | }, 180 | Select: { 181 | defaultProps: { 182 | ...chakraTheme.components.Select.defaultProps, 183 | size: 'sm' 184 | }, 185 | variants: { 186 | outline: (props: GlobalStyleProps) => ({ 187 | field: inputStyles(props) 188 | }) 189 | } 190 | }, 191 | Popover: { 192 | baseStyle: { 193 | header: { 194 | fontSize: 'lg', 195 | fontWeight: 600, 196 | border: 'none' 197 | }, 198 | content: { 199 | padding: '6px', 200 | backgroundColor: 'dialogBg', 201 | borderColor: 'dialogBorder', 202 | boxShadow: 'lg', 203 | _focus: { 204 | boxShadow: 'lg' 205 | }, 206 | _active: { 207 | boxShadow: 'lg' 208 | } 209 | }, 210 | body: { 211 | fontWeight: 400, 212 | fontSize: 'sm' 213 | }, 214 | footer: { 215 | borderTopWidth: 0, 216 | px: 3, 217 | py: 3 218 | } 219 | } 220 | }, 221 | Menu: { 222 | baseStyle: { 223 | list: { 224 | bg: 'dialogBg', 225 | borderColor: 'dialogBorder', 226 | boxShadow: '2xl', 227 | zIndex: 100, 228 | minWidth: '180px' 229 | }, 230 | item: { 231 | fontSize: 'sm', 232 | bg: 'dialogBg', 233 | _hover: { 234 | bg: 'contrastLow' 235 | }, 236 | _focus: { 237 | bg: 'transparent' 238 | }, 239 | '.chakra-menu__icon-wrapper': { 240 | display: 'flex', 241 | alignItems: 'center' 242 | } 243 | } 244 | } 245 | }, 246 | Button: { 247 | baseStyle: { 248 | lineHeight: 'unset', 249 | fontWeight: 500 250 | }, 251 | defaultProps: { 252 | ...chakraTheme.components.Button.defaultProps, 253 | size: 'sm' 254 | }, 255 | variants: { 256 | subdued: variantSubdued 257 | } 258 | }, 259 | Checkbox: { 260 | baseStyle: (props: GlobalStyleProps) => { 261 | return { 262 | ...chakraTheme.components.Checkbox.baseStyle?.(props), 263 | control: { 264 | borderWidth: '2px', 265 | borderColor: 'inputBorder', 266 | _indeterminate: { 267 | bg: 'contrastFull', 268 | borderColor: 'contrastFull', 269 | _hover: { 270 | bg: 'text', 271 | borderColor: 'text' 272 | } 273 | }, 274 | _checked: { 275 | bg: 'contrastFull', 276 | borderColor: 'contrastFull', 277 | _hover: { 278 | bg: 'contrastFull', 279 | borderColor: 'contrastFull' 280 | } 281 | }, 282 | _hover: { 283 | borderColor: 'text' 284 | }, 285 | _focusVisible: { 286 | outline: 'none', 287 | boxShadow: `inset var(--chakra-colors-text) 0 0 0 0.07rem !important`, 288 | borderColor: 'text' 289 | } 290 | } 291 | }; 292 | } 293 | }, 294 | Modal: { 295 | baseStyle: { 296 | dialog: { 297 | marginTop: '120px', 298 | bg: 'bg' 299 | }, 300 | header: { 301 | fontSize: 'xl', 302 | fontWeight: 600 303 | }, 304 | body: { 305 | fontSize: 'md' 306 | } 307 | } 308 | }, 309 | Tabs: { 310 | variants: { 311 | minimal: { 312 | tab: { 313 | fontSize: 'sm', 314 | fontWeight: 400, 315 | borderRadius: 'md', 316 | bg: 'whiteAlpha.100', 317 | px: 2, 318 | py: 0.25, 319 | border: 'solid 1px', 320 | borderColor: 'whiteAlpha.200', 321 | _selected: { 322 | bg: 'whiteAlpha.400' 323 | }, 324 | _hover: { 325 | bg: 'whiteAlpha.300' 326 | } 327 | }, 328 | tablist: { 329 | border: 'none', 330 | gap: 2 331 | }, 332 | tabpanel: { 333 | px: 0 334 | } 335 | }, 336 | line: { 337 | tab: { 338 | fontWeight: 600, 339 | fontSize: '15px', 340 | color: 'textSubtle', 341 | paddingLeft: 0, 342 | paddingRight: 0, 343 | marginBottom: { base: '0px', md: '-2px' }, 344 | borderBottom: 'solid 2px', 345 | borderBottomWidth: '2px', 346 | flexShrink: 0, 347 | borderColor: 'transparent', 348 | _notLast: { 349 | marginRight: 7 350 | }, 351 | _hover: { 352 | color: 'primary' 353 | }, 354 | _active: { 355 | color: 'primary', 356 | bg: 'transparent' 357 | }, 358 | _selected: { 359 | color: 'textPrimary', 360 | borderColor: 'primary', 361 | _hover: { 362 | color: 'textPrimary', 363 | borderColor: 'primary' 364 | } 365 | } 366 | }, 367 | tablist: { 368 | overflowX: { base: 'scroll', md: 'unset' }, 369 | scrollbarWidth: 'none', 370 | '::-webkit-scrollbar': { 371 | display: 'none' 372 | }, 373 | borderBottom: 'solid 1px', 374 | borderBottomWidth: '1px', 375 | paddingBottom: '-1px', 376 | borderColor: 'stroke' 377 | } 378 | } 379 | } 380 | }, 381 | Drawer: { 382 | baseStyle: { 383 | dialog: { 384 | bg: 'bg' 385 | } 386 | }, 387 | sizes: { 388 | // This hack adds a custom size drawer for the code drawer. 389 | codeDrawer: { 390 | dialog: { 391 | maxW: '800px' 392 | } 393 | } 394 | } 395 | }, 396 | Table: { 397 | baseStyle: { 398 | th: { 399 | textTransform: 'none', 400 | letterSpacing: 'normal', 401 | fontWeight: 'semibold' 402 | } 403 | }, 404 | sizes: { 405 | sm: { 406 | th: { 407 | px: 2, 408 | fontSize: 'sm', 409 | py: 2 410 | }, 411 | td: { 412 | px: 2, 413 | py: 1.5 414 | } 415 | }, 416 | xs: { 417 | th: { 418 | px: 1.5, 419 | py: 1 420 | }, 421 | td: { 422 | px: 1.5, 423 | py: 1 424 | } 425 | } 426 | }, 427 | variants: { 428 | bordered: { 429 | thead: { 430 | th: { 431 | border: 'solid 1px', 432 | borderColor: 'stroke' 433 | } 434 | }, 435 | tbody: { 436 | tr: { 437 | td: { 438 | border: 'solid 1px', 439 | borderColor: 'stroke' 440 | } 441 | } 442 | } 443 | }, 444 | striped: { 445 | thead: { 446 | th: { 447 | borderColor: 'transparent' 448 | } 449 | }, 450 | tbody: { 451 | tr: { 452 | '&:nth-of-type(even)': { 453 | 'th, td': { 454 | borderColor: 'transparent' 455 | } 456 | }, 457 | '&:nth-of-type(odd)': { 458 | 'th, td': { 459 | borderColor: 'transparent' 460 | }, 461 | td: { 462 | background: 'contrastLowest' 463 | } 464 | } 465 | } 466 | } 467 | } 468 | } 469 | }, 470 | Heading: { 471 | variants: { 472 | gradient: variantHeadingGradient 473 | } 474 | } 475 | } 476 | }); 477 | -------------------------------------------------------------------------------- /theme/tokens.ts: -------------------------------------------------------------------------------- 1 | import { baseTheme } from '@chakra-ui/theme'; 2 | import chroma from 'chroma-js'; 3 | export type SemanticTokenColor = keyof (typeof semanticTokens)['colors']; 4 | 5 | // xata seed colors 6 | const primary_purple = '#8566FF'; 7 | const yellow_gold = '#F6DF8A'; 8 | const apricot_orange = '#EFA764'; 9 | const crimson_red = '#DC6161'; 10 | const fresh_mint = '#57DC9C'; 11 | const soft_orchid = '#F6F4FF'; 12 | 13 | // const SEED_COLOR: chroma.Color = chroma.random().set(`hsl.s`, 0.6); 14 | // Purple 15 | 16 | const SEED_COLOR: chroma.Color = chroma(primary_purple); 17 | //const DESATURATE_GRAY_LEVEL = 3; 18 | 19 | // Blue 20 | /* const SEED_COLOR: chroma.Color = chroma(`#0084ff`); 21 | const DESATURATE_GRAY_LEVEL = 3.0; */ 22 | // const primary = chroma("#0084ff"); 23 | 24 | // This sets the mood of the theme. A blue color will result in a blue theme...etc 25 | // const SEED_COLOR: chroma.Color = chroma(`#7300ff`); 26 | // Moves from light to dark against a curve 27 | const LIGHTNESS_CURVE = [0.95, 0.85, 0.75, 0.65, 0.55, 0.45, 0.35, 0.18, 0.12, 0.08]; 28 | // Optional lightness curve for darker blacks 29 | // const GRAY_LIGHTNESS_CURVE = [0.95, 0.85, 0.75, 0.65, 0.55, 0.45, 0.35, 0.25, 0.15, 0.01]; 30 | 31 | // Moves from slightly desaturated to saturated to desaturated 32 | const SATURATION_CURVE = [0.12, 0.1, 0.08, 0.04, 0, 0, 0.04, 0.08, 0.1, 0.12]; 33 | 34 | // A higher number will result in less saturated colors 35 | 36 | /** 37 | * Function to shift the hue of any passed chroma value 38 | * 39 | * @param color The chroma color to shift from 40 | * @param amount The amount (out of 360) to shift the hue by 41 | * @returns Returns a chroma color with the hue shifted by the amount 42 | */ 43 | // eslint-disable-next-line 44 | const shiftHue = (color: chroma.Color, amount: number) => { 45 | const colorHue = chroma(SEED_COLOR).get(`hsl.h`); 46 | if (colorHue + amount > 360) { 47 | return chroma(color).set(`hsl.h`, colorHue + amount - 360); 48 | } 49 | return chroma(color).set(`hsl.h`, colorHue + amount); 50 | }; 51 | 52 | // const primary = chroma(primary_purple); 53 | const teal = chroma(SEED_COLOR.set(`hsl.h`, 180)); 54 | const red = chroma(crimson_red); 55 | const orange = chroma(apricot_orange); 56 | const yellow = chroma(yellow_gold); 57 | const green = chroma(fresh_mint); 58 | const blue = chroma(SEED_COLOR.set(`hsl.h`, 196)); 59 | 60 | // palette generated from https://gka.github.io/palettes/ 61 | // input colors: #ffffff, #f6f4ff, #0f0c1b 62 | const grayPalette: string[] = [ 63 | '#fbfaff', 64 | '#dddce2', 65 | '#c0bfc6', 66 | '#a3a2ab', 67 | '#888690', 68 | '#6d6c77', 69 | '#54525e', 70 | '#3c3a46', 71 | '#252330', 72 | '#0f0c1b' 73 | ]; 74 | 75 | // palette generated from https://gka.github.io/palettes/ 76 | // input colors: #f0ecff, #8566ff (primary purple), #0f0c1b (midnight plum) 77 | // no bezier interpolation 78 | const purplePallette: string[] = [ 79 | '#f0ecff', 80 | '#dacaff', 81 | '#c1a9ff', 82 | '#a688ff', 83 | '#8768ff', 84 | '#6c53cd', 85 | '#53419c', 86 | '#3c2f6d', 87 | '#251f42', 88 | '#0f0c1b' 89 | ]; 90 | 91 | // palette generated from https://gka.github.io/palettes/ 92 | // input colors: #fef6ff, #df9cf7, #0f0c1b (midnight plum) 93 | // no bezier interpolation and no correct lightness 94 | const pinkPalette: string[] = [ 95 | '#fef6ff', 96 | '#f7e2fd', 97 | '#f0cefb', 98 | '#e9bafa', 99 | '#e2a6f8', 100 | '#c88cdf', 101 | '#9a6cae', 102 | '#6b4c7d', 103 | '#3d2c4c', 104 | '#0f0c1b' 105 | ]; 106 | 107 | const generatePalette = (palette: string[]): { [key: string]: string } => { 108 | const nameScale = [`50`, `100`, `200`, `300`, `400`, `500`, `600`, `700`, `800`, `900`]; 109 | 110 | return nameScale.reduce((a, v, index) => { 111 | const colorInPalette = palette[index]; 112 | return { ...a, [v]: colorInPalette }; 113 | }, {}); 114 | }; 115 | 116 | // Given a color and some shift values, will return a theme pallette of 10 colors 117 | const generateColorSet = (color: chroma.Color, desaturate?: number, curve?: number[]) => { 118 | const lightnessCurve = curve || LIGHTNESS_CURVE; 119 | const desaturatedColor = chroma(color).desaturate(desaturate || 0); 120 | const lightnessGoal = desaturatedColor.get(`hsl.l`); 121 | const closestLightness = lightnessCurve.reduce((prev, curr) => 122 | Math.abs(curr - lightnessGoal) < Math.abs(prev - lightnessGoal) ? curr : prev 123 | ); 124 | const baseColorIndex = lightnessCurve.findIndex((l) => l === closestLightness); 125 | 126 | const colors = lightnessCurve 127 | .map((l) => desaturatedColor.set(`hsl.l`, l)) 128 | .map((color) => chroma(color)) 129 | .map((color, i) => { 130 | const saturationDelta = SATURATION_CURVE[i] - SATURATION_CURVE[baseColorIndex]; 131 | // If there is no hue, there is no need to saturate 132 | // In Chroma, black and white get NaN for hue 133 | if (isNaN(color.get('hsl.h'))) { 134 | return color.set('hsl.h', 0).hex(); 135 | } 136 | // Otherwise, saturate according to the curve 137 | return saturationDelta >= 0 138 | ? color.saturate(saturationDelta).hex() 139 | : color.desaturate(saturationDelta * -1).hex(); 140 | }); 141 | const nameScale = [`50`, `100`, `200`, `300`, `400`, `500`, `600`, `700`, `800`, `900`]; 142 | // TODO: Figure a better way to assign this type 143 | const colorObject: ColorSet = nameScale.reduce((a, v, index) => { 144 | const colorInPalette = colors[index]; 145 | return { ...a, [v]: colorInPalette }; 146 | }, {}); 147 | 148 | return colorObject; 149 | }; 150 | 151 | export const colors = { 152 | SEED_COLOR: SEED_COLOR.hex(), 153 | gray: generatePalette(grayPalette), 154 | teal: generateColorSet(teal), 155 | red: generateColorSet(red), 156 | orange: generateColorSet(orange), 157 | yellow: generateColorSet(yellow), 158 | green: generateColorSet(green), 159 | purple: generatePalette(purplePallette), 160 | blue: generateColorSet(blue), 161 | pink: generatePalette(pinkPalette), 162 | // the primary color is the purple palette 163 | primary: generatePalette(purplePallette), 164 | // the accent color is the pink palette 165 | accent: generatePalette(pinkPalette) 166 | }; 167 | 168 | /** 169 | * Will adjust a foreground color until it passes WCAG AA contrast ratio (4.5) 170 | * 171 | * @param foreground The foreground color to make readable 172 | * @param background The background color to make readable against 173 | * @returns Returns an adjusted foreground color as a hex that will have a 4.5 contract rating against the provided background. 174 | */ 175 | const makeReadable = (foreground: chroma.Color, background: chroma.Color) => { 176 | const contrastRatio = chroma.contrast(foreground, background); 177 | if (contrastRatio > 4.5) { 178 | return foreground.hex(); 179 | } else { 180 | let newForeground = foreground; 181 | let newContrastRatio = chroma.contrast(newForeground, background); 182 | while (newContrastRatio < 4.5) { 183 | if (background.luminance() > 0.5) { 184 | newForeground = chroma(newForeground).darken(0.05); 185 | } else { 186 | newForeground = chroma(newForeground).brighten(0.05); 187 | } 188 | newContrastRatio = chroma.contrast(newForeground, background); 189 | } 190 | return newForeground.hex(); 191 | } 192 | }; 193 | 194 | type ColorSet = { 195 | [index: string]: string; 196 | }; 197 | 198 | /** 199 | * Color function to make some readable text based upon the background color 200 | * 201 | * @param color The foreground color to make readable 202 | * @param mode The theme you are targeting (light or dark) 203 | * @returns Returns an adjusted foreground color as a hex that will have a 4.5 contract rating against the provided background. 204 | */ 205 | const quickReadableText = (color: chroma.Color, mode: `light` | `dark`) => { 206 | return makeReadable(color, chroma(mode === `dark` ? colors.gray[`700`] : colors.gray[`50`])); 207 | }; 208 | 209 | // Contrast scale is what we use to define our tint/shade scale 210 | // A separate object is needed so we can reference it within the semanticTokens 211 | const contrastScale = { 212 | empty: { light: 'white', dark: 'gray.900' }, 213 | lowest: { 214 | light: chroma(colors.gray['50']).darken(0.1).hex(), 215 | dark: chroma(colors.gray['900']).brighten(0.3).hex() 216 | }, 217 | low: { 218 | light: chroma(colors.gray['50']).darken(0.5).hex(), 219 | dark: chroma(colors.gray['800']).brighten(0.3).hex() 220 | }, 221 | medium: { light: 'gray.300', dark: 'gray.500' }, 222 | high: { light: 'gray.600', dark: 'gray.300' }, 223 | highest: { light: 'gray.800', dark: 'gray.200' }, 224 | full: { light: 'gray.800', dark: 'gray.100' } 225 | }; 226 | 227 | type Token = { 228 | name: string; 229 | values: { 230 | _light: string; 231 | _dark: string; 232 | description?: string; 233 | }; 234 | }; 235 | 236 | const generateColorArrayFromObject = (obj: Token) => { 237 | const array = Object.keys(obj).map((key) => { 238 | const color = obj[key as keyof Token]; // Assert the key as keyof Token 239 | 240 | if (typeof color === 'string') { 241 | // Handle the case when color is a string 242 | return { 243 | name: key, 244 | values: { 245 | light: color, 246 | dark: color, 247 | description: '' // Provide a default description value 248 | } 249 | }; 250 | } else { 251 | return { 252 | name: key, 253 | values: { 254 | light: color._light, 255 | dark: color._dark, 256 | description: color.description 257 | } 258 | }; 259 | } 260 | }); 261 | 262 | return array; 263 | }; 264 | 265 | const generateColorsObjNoDesc = ( 266 | brandColorsObj: Record 267 | ): Record => { 268 | return Object.keys(brandColorsObj).reduce((acc, key) => { 269 | const { description, ...rest } = brandColorsObj[key]; 270 | return { ...acc, [key]: rest }; 271 | }, {}); 272 | }; 273 | 274 | const brandColorsObj: any = { 275 | primary: { _light: `purple.500`, _dark: `purple.300`, description: `Primary brand color` }, 276 | accent: { _light: `pink.500`, _dark: `pink.300`, description: `Accent brand color` }, 277 | success: { _light: `green.500`, _dark: `green.300`, description: `Success state` }, 278 | danger: { _light: `red.500`, _dark: `red.300`, description: `Danger state` }, 279 | warning: { _light: `yellow.500`, _dark: `yellow.300`, description: `Warning state` }, 280 | info: { _light: `blue.500`, _dark: `blue.300`, description: `Info state` }, 281 | ghost: { _light: `white`, _dark: `white` }, 282 | ink: { _light: `gray.900`, _dark: `gray.900` } 283 | }; 284 | 285 | const brandColorsObjNoDesc = generateColorsObjNoDesc(brandColorsObj); 286 | export const brandColors = generateColorArrayFromObject(brandColorsObj); 287 | 288 | const contrastColorsObj: any = { 289 | contrastEmpty: { 290 | _light: contrastScale.empty.light, 291 | _dark: contrastScale.empty.dark 292 | }, 293 | contrastLowest: { 294 | _light: contrastScale.lowest.light, 295 | _dark: contrastScale.lowest.dark 296 | }, 297 | contrastLow: { 298 | _light: contrastScale.low.light, 299 | _dark: contrastScale.low.dark 300 | }, 301 | contrastMedium: { 302 | _light: contrastScale.medium.light, 303 | _dark: contrastScale.medium.dark 304 | }, 305 | contrastHigh: { 306 | _light: contrastScale.high.light, 307 | _dark: contrastScale.high.dark 308 | }, 309 | contrastHighest: { 310 | _light: contrastScale.highest.light, 311 | _dark: contrastScale.highest.dark 312 | }, 313 | contrastFull: { 314 | _light: contrastScale.full.light, 315 | _dark: contrastScale.full.dark 316 | } 317 | }; 318 | 319 | export const contrastColors = generateColorArrayFromObject(contrastColorsObj); 320 | 321 | const textColorsObj: any = { 322 | text: { _light: 'gray.800', _dark: 'gray.50' }, 323 | textSubtle: { 324 | _light: quickReadableText(chroma('#595073'), `light`), 325 | _dark: quickReadableText(chroma('#F1D7FF'), `dark`) 326 | }, 327 | textPlaceholder: { _light: 'gray.500', _dark: 'gray.500' }, 328 | title: { _light: `gray.900`, _dark: `white` }, 329 | titleInvert: { _light: `white`, _dark: `gray.900` }, 330 | textInvert: { _light: 'gray.50', _dark: 'gray.800' }, 331 | textPrimary: { 332 | _light: quickReadableText(chroma(colors.primary[500]), `light`), 333 | _dark: quickReadableText(chroma(colors.primary[300]), `dark`) 334 | }, 335 | textAccent: { 336 | _light: quickReadableText(chroma(colors.accent[400]), `light`), 337 | _dark: quickReadableText(chroma(colors.accent[500]), `dark`) 338 | }, 339 | textWarning: { 340 | _light: quickReadableText(chroma(colors.orange[400]), `light`), 341 | _dark: quickReadableText(chroma(colors.orange[500]), `dark`) 342 | }, 343 | textSuccess: { 344 | _light: quickReadableText(chroma(colors.green[400]), `light`), 345 | _dark: quickReadableText(chroma(colors.green[500]), `dark`) 346 | }, 347 | textDanger: { 348 | _light: quickReadableText(chroma(colors.red[400]), `light`), 349 | _dark: quickReadableText(chroma(colors.red[500]), `dark`) 350 | }, 351 | textInfo: { 352 | _light: quickReadableText(chroma(colors.blue[400]), `light`), 353 | _dark: quickReadableText(chroma(colors.blue[500]), `dark`) 354 | } 355 | }; 356 | 357 | export const textColors = generateColorArrayFromObject(textColorsObj); 358 | 359 | const bgColorsObj: any = { 360 | bg: { _light: '#FDFDFF', _dark: 'gray.900' }, 361 | bgAlternate: { _light: soft_orchid, _dark: chroma(colors.gray[900]).brighten(0.2).hex() }, 362 | bgAlternateMedium: { 363 | _light: '#e4dffa', 364 | _dark: chroma(colors.gray[900]).brighten(0.5).hex() 365 | }, 366 | bgPrimary: { _light: 'purple.500', _dark: 'purple.200' }, 367 | bgHighlight: { _light: 'purple.500', _dark: 'purple.500' }, 368 | bgDanger: { 369 | _light: 'red.50', 370 | _dark: 'red.700' 371 | }, 372 | bgWarning: { _light: 'orange.50', _dark: 'orange.700' }, 373 | bgSuccess: { _light: 'green.50', _dark: 'green.700' }, 374 | bgInfo: { _light: 'blue.50', _dark: 'blue.700' }, 375 | bgInvert: { _light: 'gray.800', _dark: 'white' } 376 | }; 377 | 378 | export const bgColors = generateColorArrayFromObject(bgColorsObj); 379 | 380 | const shadowColorsObj: any = { 381 | shadowOuterBorder: { 382 | _light: 'blackAlpha.100', 383 | _dark: 'whiteAlpha.50' 384 | }, 385 | shadowInnerBorder: { 386 | _light: contrastScale.low.light, 387 | _dark: contrastScale.low.dark 388 | } 389 | }; 390 | 391 | export const shadowColors = generateColorArrayFromObject(shadowColorsObj); 392 | 393 | export const semanticTokens = { 394 | colors: { 395 | seed: { _light: SEED_COLOR.hex(), _dark: SEED_COLOR.hex() }, 396 | 397 | ...brandColorsObjNoDesc, 398 | ...contrastColorsObj, 399 | ...textColorsObj, 400 | ...bgColorsObj, 401 | ...shadowColorsObj, 402 | stroke: { 403 | _light: contrastScale.low.light, 404 | _dark: contrastScale.low.dark 405 | }, 406 | dialogBg: { _light: contrastScale.lowest.light, _dark: contrastScale.lowest.dark }, 407 | dialogBorder: { 408 | _light: contrastScale.lowest.light, 409 | // _dark: chroma(contrastScale.lowest.dark).brighten(0.5).hex() 410 | _dark: contrastScale.low.dark 411 | }, 412 | // Tokens acording to the Figma designs that can be found here https://www.figma.com/file/RQlFwhHSMdbdVMZC4WtmKa/UI-Library?node-id=73%3A3025 413 | inputBg: { 414 | _light: 'white', 415 | _dark: colors.gray[900] 416 | }, 417 | inputBorder: { _light: 'gray.200', _dark: 'gray.700' }, 418 | codeTitleBg: { 419 | _light: 'gray.50', 420 | _dark: chroma(colors.gray[800]).brighten(0.1).hex() 421 | }, 422 | codeHeaderBg: { 423 | _light: '#4E466F', 424 | _dark: '#4E466F' 425 | }, 426 | codeBg: { 427 | _light: '#1B1532', 428 | _dark: '#1B1532' 429 | } 430 | } 431 | }; 432 | 433 | // breakpoints 434 | export const breakpoints = { 435 | xs: '23em', // 375px 436 | sm: '30em', // 480px 437 | md: '48em', // 768px 438 | lg: '62em', // 992px 439 | xl: '80em', // 1280px 440 | '2xl': '96em', // 1536px 441 | '3xl': '120em' // 1920px 442 | }; 443 | 444 | export const borderRadius = { 445 | radii: { 446 | none: '0', 447 | sm: '0', 448 | base: '0', 449 | md: '0', 450 | lg: '0', 451 | xl: '0', 452 | '2xl': '0', 453 | '3xl': '0', 454 | full: '0' 455 | } 456 | }; 457 | 458 | export const shadows = { 459 | ...baseTheme.shadows, 460 | outline: `0 0 0 8px var(--chakra-colors-shadowOuterBorder),0 0 1px var(--chakra-colors-shadowInnerBorder)`, 461 | under: `rgba(0, 0, 0, 0.25) 0px 24px 20px -20px`, 462 | shine: `0px 4px 100px 50px rgba(201, 163, 251, 0.3)` 463 | }; 464 | 465 | export const fonts = { 466 | heading: 467 | 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', 468 | body: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', 469 | mono: 'JetBrainsMono, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' 470 | }; 471 | 472 | // Occassionally we need to get the raw hex value of a token. Charkra's useToken hook will 473 | // return the css var(), but not the raw value. 474 | export const getComputedTokenValues = (variables: string[]) => { 475 | const style = getComputedStyle(document.documentElement); 476 | const computedValues: string[] = []; 477 | variables.map((item) => { 478 | const cssValue = style.getPropertyValue(item).trim(); // sometimes this needs trimming 479 | computedValues.push(cssValue); 480 | }); 481 | return computedValues; 482 | }; 483 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "jsx": "preserve", 15 | "isolatedModules": true, 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "~/*": ["./*"] 24 | } 25 | }, 26 | "include": [ 27 | "next-env.d.ts", 28 | "**/*.ts", 29 | "**/*.tsx", 30 | ".next/types/**/*.ts", 31 | "images.d.ts", 32 | "scripts/one-click.mjs", 33 | "scripts/seed.mjs", 34 | "scripts/cleanup.mjs" 35 | ], 36 | "exclude": ["node_modules"] 37 | } 38 | -------------------------------------------------------------------------------- /utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const IMAGE_SIZE = 294; 2 | export const IMAGES_PER_PAGE_COUNT = 12; 3 | -------------------------------------------------------------------------------- /utils/metadata.ts: -------------------------------------------------------------------------------- 1 | // Grabs the demensions and other info of a transformed image 2 | interface OriginalImageInfo { 3 | file_size: number; 4 | width: number; 5 | height: number; 6 | format: string; 7 | } 8 | 9 | interface ImageMetadata { 10 | width: number; 11 | height: number; 12 | original: OriginalImageInfo; 13 | } 14 | 15 | // Grabs the dimensions and other info of a transformed image 16 | export const fetchMetadata = async (metadataUrl: string): Promise => { 17 | try { 18 | const response = await fetch(metadataUrl); 19 | if (!response.ok) { 20 | throw new Error('Failed to fetch metadata'); 21 | } 22 | const data: ImageMetadata = await response.json(); 23 | return data; 24 | } catch (error) { 25 | console.error('Error fetching metadata:', error); 26 | return null; 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /utils/xata.ts: -------------------------------------------------------------------------------- 1 | // Generated by Xata Codegen 0.29.4. Please do not edit. 2 | import type { BaseClientOptions, SchemaInference, XataRecord } from '@xata.io/client'; 3 | import { buildClient } from '@xata.io/client'; 4 | 5 | const tables = [ 6 | { 7 | name: 'tag', 8 | columns: [{ name: 'name', type: 'string', notNull: true, defaultValue: 'gallery' }], 9 | revLinks: [{ column: 'tag', table: 'tag-to-image' }] 10 | }, 11 | { 12 | name: 'image', 13 | columns: [ 14 | { name: 'name', type: 'string', notNull: true, defaultValue: 'Image' }, 15 | { name: 'image', type: 'file', file: { defaultPublicAccess: true } } 16 | ], 17 | revLinks: [{ column: 'image', table: 'tag-to-image' }] 18 | }, 19 | { 20 | name: 'tag-to-image', 21 | columns: [ 22 | { name: 'image', type: 'link', link: { table: 'image' } }, 23 | { name: 'tag', type: 'link', link: { table: 'tag' } } 24 | ] 25 | } 26 | ] as const; 27 | 28 | export type SchemaTables = typeof tables; 29 | export type InferredTypes = SchemaInference; 30 | 31 | export type Tag = InferredTypes['tag']; 32 | export type TagRecord = Tag & XataRecord; 33 | 34 | export type Image = InferredTypes['image']; 35 | export type ImageRecord = Image & XataRecord; 36 | 37 | export type TagToImage = InferredTypes['tag-to-image']; 38 | export type TagToImageRecord = TagToImage & XataRecord; 39 | 40 | export type DatabaseSchema = { 41 | tag: TagRecord; 42 | image: ImageRecord; 43 | 'tag-to-image': TagToImageRecord; 44 | }; 45 | 46 | const DatabaseClient = buildClient(); 47 | 48 | const defaultOptions = { 49 | databaseURL: 'https://sample-databases-v0sn1n.us-east-1.xata.sh/db/gallery-example' 50 | }; 51 | 52 | export class XataClient extends DatabaseClient { 53 | constructor(options?: BaseClientOptions) { 54 | super({ ...defaultOptions, ...options }, tables); 55 | } 56 | } 57 | 58 | let instance: XataClient | undefined = undefined; 59 | 60 | export const getXataClient = () => { 61 | if (instance) return instance; 62 | 63 | instance = new XataClient(); 64 | return instance; 65 | }; 66 | --------------------------------------------------------------------------------