├── .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 | 
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 | [](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 |
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 |
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 | }
116 | >
117 | Source on GitHub
118 |
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 |
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 |
--------------------------------------------------------------------------------