├── .eslintrc
├── .github
└── workflows
│ └── update.yml
├── .gitignore
├── .husky
├── .lintstagedrc.js
├── post-commit
└── pre-commit
├── .prettierignore
├── .prettierrc
├── CLAUDE.md
├── LICENSE
├── README.md
├── __mocks__
└── nanoid.js
├── ansible
├── artbot_update.yml
├── galaxy-requirements.yml
└── inventory.yml
├── app
├── (content)
│ ├── about
│ │ └── page.tsx
│ ├── changelog
│ │ ├── [page]
│ │ │ ├── _component
│ │ │ │ └── ChangelogEntry.tsx
│ │ │ └── page.tsx
│ │ ├── _updates
│ │ │ ├── 2024.07.05.md
│ │ │ ├── 2024.07.09.md
│ │ │ ├── 2024.07.17.md
│ │ │ ├── 2024.08.29.md
│ │ │ ├── 2024.09.06.md
│ │ │ └── 2024.11.01.md
│ │ └── page.tsx
│ ├── create
│ │ ├── _component
│ │ │ ├── CustomQueryParamsHandler.tsx
│ │ │ ├── ForceWorkerModal.tsx
│ │ │ ├── PromptActionPanel.tsx
│ │ │ ├── PromptInputForm.tsx
│ │ │ └── PromptStickyCreate.tsx
│ │ ├── _hook
│ │ │ └── useCreateImageRequest.tsx
│ │ ├── json
│ │ │ ├── _component
│ │ │ │ └── JsonInput.tsx
│ │ │ ├── dirty-json.d.ts
│ │ │ └── page.tsx
│ │ └── page.tsx
│ ├── faq
│ │ ├── _component
│ │ │ └── FAQ_Kudos.tsx
│ │ └── page.tsx
│ ├── images
│ │ └── page.tsx
│ ├── info
│ │ ├── models
│ │ │ ├── _component
│ │ │ │ └── ModelsInfo.tsx
│ │ │ └── page.tsx
│ │ └── page.tsx
│ ├── layout.tsx
│ ├── pending
│ │ └── page.tsx
│ ├── privacy
│ │ └── page.tsx
│ ├── settings
│ │ ├── _component
│ │ │ ├── AddEditSharedKey.tsx
│ │ │ ├── AddEditWebhook.tsx
│ │ │ ├── Apikey.tsx
│ │ │ ├── GoogleAuth.tsx
│ │ │ ├── SharedKeys.tsx
│ │ │ ├── WebhookUrls.tsx
│ │ │ └── WorkerList.tsx
│ │ ├── data
│ │ │ └── page.tsx
│ │ ├── page.tsx
│ │ └── workers
│ │ │ └── page.tsx
│ ├── terms
│ │ └── page.tsx
│ └── user
│ │ ├── messages
│ │ └── page.tsx
│ │ └── page.tsx
├── (home)
│ ├── layout.tsx
│ ├── not-found.tsx
│ └── page.tsx
├── _api
│ ├── artbot
│ │ └── debugSaveResponse.ts
│ ├── civitai
│ │ ├── civitaiWorker.ts
│ │ └── models.ts
│ ├── horde
│ │ ├── check.ts
│ │ ├── check_webworker.ts
│ │ ├── download.ts
│ │ ├── generate.ts
│ │ ├── heartbeat.ts
│ │ ├── messages.ts
│ │ ├── rateLimiter.ts
│ │ ├── status.ts
│ │ └── workers.ts
│ ├── models
│ │ └── index.tsx
│ └── presets
│ │ └── index.tsx
├── _components
│ ├── AdvancedOptions
│ │ ├── AddEmbedding.tsx
│ │ ├── AddWorkflow
│ │ │ └── index.tsx
│ │ ├── AdditionalOptions.tsx
│ │ ├── ClipSkip.tsx
│ │ ├── FaceFixers.tsx
│ │ ├── Guidance.tsx
│ │ ├── HiresFix.tsx
│ │ ├── HordeSettings.tsx
│ │ ├── ImageCount.tsx
│ │ ├── ImageOrientation.tsx
│ │ ├── ImageOrientation_Custom.tsx
│ │ ├── ImageProcessing.tsx
│ │ ├── LoRAs
│ │ │ ├── AddLora.tsx
│ │ │ ├── EmbeddingSettingsCard.tsx
│ │ │ ├── LoraDetails.tsx
│ │ │ ├── LoraFilter.tsx
│ │ │ ├── LoraImage.tsx
│ │ │ ├── LoraKeywords.tsx
│ │ │ ├── LoraSearch.tsx
│ │ │ ├── LoraSettingsCard.tsx
│ │ │ ├── _Embeddings.json
│ │ │ ├── _LORAs.json
│ │ │ └── loraSearch.module.css
│ │ ├── ModelSelect
│ │ │ ├── index.tsx
│ │ │ ├── modalWrapper.tsx
│ │ │ ├── modelSelectComponent.test.tsx
│ │ │ ├── modelSelectComponent.tsx
│ │ │ └── models.json
│ │ ├── OptionLabel.tsx
│ │ ├── SamplerSelect.tsx
│ │ ├── Seed.tsx
│ │ ├── Steps.tsx
│ │ ├── StylePresetModal.tsx
│ │ ├── StylePresetSelect
│ │ │ ├── index.tsx
│ │ │ └── stylePresetSelectComponent.tsx
│ │ ├── UploadImage
│ │ │ ├── index.tsx
│ │ │ └── uploadImage.module.css
│ │ ├── Upscalers.tsx
│ │ └── index.tsx
│ ├── AppInit
│ │ ├── AppInitComponent.tsx
│ │ └── index.tsx
│ ├── Button
│ │ ├── button.module.css
│ │ └── index.tsx
│ ├── Carousel
│ │ ├── CarouselArrowButtons.tsx
│ │ ├── CarouselControls.tsx
│ │ ├── CarouselImage.tsx
│ │ ├── carousel.module.css
│ │ └── index.tsx
│ ├── ComboBox.tsx
│ ├── ContentWrapper.tsx
│ ├── DropdownMenu
│ │ ├── dropdownMenu.module.css
│ │ └── index.tsx
│ ├── Footer
│ │ ├── AnimatedEmoji.tsx
│ │ ├── buildId.tsx
│ │ ├── footer.module.css
│ │ └── index.tsx
│ ├── FrontPage
│ │ ├── NoiseToImage.tsx
│ │ └── Typewriter.tsx
│ ├── Fullscreen
│ │ ├── fullscreen.module.css
│ │ └── index.tsx
│ ├── Gallery
│ │ ├── handleDownloads.ts
│ │ └── index.tsx
│ ├── GalleryImageCardOverlay
│ │ ├── galleryImageCardOverlay.module.css
│ │ └── index.tsx
│ ├── HeaderNav
│ │ ├── HamburgerNavButton.tsx
│ │ ├── HeaderNav_ArtBotOffline.tsx
│ │ ├── HeaderNav_ForceWorker.tsx
│ │ ├── HeaderNav_HordeOffline.tsx
│ │ ├── HeaderNav_HordePerformance.tsx
│ │ ├── HeaderNav_Messages.tsx
│ │ ├── HeaderNav_PendingJobs.tsx
│ │ ├── HeaderNav_UserKudos.tsx
│ │ ├── _HeaderNav_IconWrapper.tsx
│ │ └── index.tsx
│ ├── Image.tsx
│ ├── ImageDetails.tsx
│ ├── ImageThumbnail.tsx
│ ├── ImageThumbnailV2.tsx
│ ├── ImageView
│ │ ├── ImageViewImage.tsx
│ │ ├── ImageViewInfoContainer.tsx
│ │ ├── ImageViewProvider.tsx
│ │ ├── ImageViewSourceImage.tsx
│ │ ├── _hooks
│ │ │ ├── useGoogleDriveUpload.tsx
│ │ │ └── useWebhookUpload.tsx
│ │ ├── downloadImageWorker.ts
│ │ ├── imageView.module.css
│ │ ├── imageViewActions.tsx
│ │ └── index.tsx
│ ├── ImageView_Pending
│ │ ├── ImageView_PendingStatus.tsx
│ │ └── index.tsx
│ ├── Input.tsx
│ ├── Linker
│ │ ├── index.tsx
│ │ └── linker.module.css
│ ├── Masonry
│ │ ├── MasonryItem.tsx
│ │ ├── MasonryLayout.tsx
│ │ ├── index.tsx
│ │ └── masonry.module.css
│ ├── MobileFooter
│ │ ├── index.tsx
│ │ └── mobileFooter.module.css
│ ├── Modal
│ │ ├── index.tsx
│ │ └── modal.module.css
│ ├── Modal_DeleteConfirmation.tsx
│ ├── Modal_UserKudos.tsx
│ ├── Modals
│ │ └── Modal_HordePerformance.tsx
│ ├── ModifyWorker.tsx
│ ├── MyWorkerSummary.tsx
│ ├── NotificationBanners
│ │ ├── BetaWarningBanner
│ │ │ └── index.tsx
│ │ └── NotificationsManager
│ │ │ └── index.tsx
│ ├── NumberInput
│ │ ├── index.tsx
│ │ └── numberInput.module.css
│ ├── PageTitle.tsx
│ ├── ParticleAnimation
│ │ ├── index.tsx
│ │ └── particleAnimation.module.css
│ ├── PendingImageOverlay
│ │ ├── index.tsx
│ │ └── pendingImageOverlay.module.css
│ ├── PendingImagePanelStats.tsx
│ ├── PendingImagesPanel
│ │ ├── PendingImagesPanel_ClearButton.tsx
│ │ ├── PendingImagesPanel_FilterButton.tsx
│ │ └── index.tsx
│ ├── Portal.tsx
│ ├── PromptLibrary
│ │ ├── PromptHistoryCard.tsx
│ │ └── index.tsx
│ ├── PromptWarning.tsx
│ ├── Section.tsx
│ ├── SectionTitle.tsx
│ ├── Select.tsx
│ ├── Spinner
│ │ ├── index.tsx
│ │ └── spinner.module.css
│ ├── StyleTags
│ │ └── index.tsx
│ ├── Switch
│ │ ├── index.tsx
│ │ └── switch.module.css
│ ├── Text
│ │ └── index.tsx
│ ├── TotalImagesGenerated
│ │ ├── TotalImagesGenerated.tsx
│ │ └── TotalImagesGeneratedLive.tsx
│ └── WorkerDetailsCard.tsx
├── _controllers
│ ├── pendingJobs
│ │ ├── checkForWaitingJobs.test.ts
│ │ ├── checkForWaitingJobs.ts
│ │ ├── checkPendingJobs.test.ts
│ │ ├── checkPendingJobs.ts
│ │ ├── downloadPendingImages.ts
│ │ ├── index.ts
│ │ ├── loadPendingImages.ts
│ │ └── updatePendingImage.ts
│ └── toastController.ts
├── _data-models
│ ├── AppConstants.ts
│ ├── AppSettings.test.ts
│ ├── AppSettings.ts
│ ├── ArtBotHordeJob.ts
│ ├── CacheMap.ts
│ ├── Civitai.ts
│ ├── ClientHeader.ts
│ ├── HordeTeams.ts
│ ├── ImageFile_Dexie.ts
│ ├── ImageParamsForHordeApi.test.ts
│ ├── ImageParamsForHordeApi.ts
│ ├── ManageWorker.ts
│ ├── PromptInput.test.ts
│ ├── PromptInput.ts
│ └── TaskQueue.ts
├── _db
│ ├── ImageFiles.ts
│ ├── appSettings.ts
│ ├── dexie.ts
│ ├── favorites.ts
│ ├── hordeJobs.ts
│ ├── imageEnhancementModules.ts
│ ├── imageRequests.ts
│ ├── jobTransactions.ts
│ └── promptsHistory.ts
├── _hooks
│ ├── useApplyPreset.tsx
│ ├── useCarouselDots.tsx
│ ├── useCivitai.tsx
│ ├── useContainerWidth.tsx
│ ├── useCustomQueryParams.tsx
│ ├── useEffectOnce.tsx
│ ├── useFavorite.tsx
│ ├── useFetch.tsx
│ ├── useFetchImages.tsx
│ ├── useGoogleAuth.tsx
│ ├── useHordeApiKey.tsx
│ ├── useImageDetails.tsx
│ ├── useImageSize.tsx
│ ├── useIntersectionObserver.tsx
│ ├── useIsomorphicLayoutEffect.tsx
│ ├── useLockedBody.tsx
│ ├── useMyWorkerDetails.tsx
│ ├── usePendingJob.tsx
│ ├── usePromptInputValidation.tsx
│ ├── useRerollImage.tsx
│ ├── useResizeObserver.tsx
│ ├── useUndoPrompt.tsx
│ ├── useWindowSize.tsx
│ └── useWorkerDetails.ts
├── _providers
│ ├── ModalProvider.tsx
│ └── PromptInputProvider.tsx
├── _stores
│ ├── AppStore.ts
│ ├── CreateImageStore.ts
│ ├── GalleryStore.ts
│ ├── ImageStore.ts
│ ├── ModelStore.ts
│ ├── PendingImagesStore.test.ts
│ ├── PendingImagesStore.ts
│ ├── UserStore.test.ts
│ └── UserStore.ts
├── _types
│ ├── ArtbotTypes.ts
│ ├── CivitaiTypes.ts
│ ├── HordeTypes.ts
│ └── google-api.ts
├── _utils
│ ├── arrayUtils.ts
│ ├── browserUtils.ts
│ ├── debounce.test.ts
│ ├── debounce.ts
│ ├── deepEqual.test.ts
│ ├── deepEqual.ts
│ ├── fileUtils.ts
│ ├── hordeUtils.ts
│ ├── imageUtils.ts
│ ├── inputUtils.ts
│ ├── numberUtils.test.ts
│ ├── numberUtils.ts
│ ├── sleep.ts
│ ├── stringUtils.ts
│ ├── throttle.ts
│ └── urlUtils.ts
├── api
│ ├── debug
│ │ └── save-response
│ │ │ └── route.ts
│ ├── heartbeat
│ │ └── route.ts
│ ├── status
│ │ ├── counter
│ │ │ └── images
│ │ │ │ └── route.ts
│ │ └── route.ts
│ └── styles
│ │ ├── route.ts
│ │ └── styleTags.json
├── globals.css
├── manifest.json
└── sw.ts
├── buildId.json
├── ecosystem.config.js
├── generateBuildId.js
├── global.d.ts
├── jest.config.js
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
├── artbot-logo.png
├── favicon.ico
├── front-page
│ ├── artbot_poster.png
│ ├── astronaut.png
│ ├── brisket.jpg
│ ├── chalet.png
│ ├── chipmunk.png
│ ├── himalays.png
│ ├── industrial-lines.png
│ ├── mech_brain.png
│ ├── penguin_surfing.png
│ ├── raven.png
│ ├── refined-penguin.png
│ ├── sf.png
│ ├── steampunk-pc.png
│ ├── super-penguin.png
│ └── wasteland-poster.png
├── icons
│ ├── 100.png
│ ├── 1024.png
│ ├── 114.png
│ ├── 120.png
│ ├── 128.png
│ ├── 144.png
│ ├── 152.png
│ ├── 16.png
│ ├── 167.png
│ ├── 180.png
│ ├── 192.png
│ ├── 20.png
│ ├── 256.png
│ ├── 29.png
│ ├── 32.png
│ ├── 40.png
│ ├── 50.png
│ ├── 512.png
│ ├── 57.png
│ ├── 58.png
│ ├── 60.png
│ ├── 64.png
│ ├── 72.png
│ ├── 76.png
│ ├── 80.png
│ └── 87.png
├── not-found.png
├── painting_bot.png
├── random_noise.jpg
└── tile.png
├── tailwind.config.ts
└── tsconfig.json
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "extends": [
4 | "eslint:recommended",
5 | "plugin:@typescript-eslint/recommended",
6 | "next/core-web-vitals",
7 | "prettier"
8 | ],
9 | "ignorePatterns": ["__mocks__/*"],
10 | "parser": "@typescript-eslint/parser",
11 | "parserOptions": {
12 | "project": "./tsconfig.json"
13 | },
14 | "rules": {
15 | "@typescript-eslint/no-unused-vars": ["warn"],
16 | "prefer-const": [
17 | "warn",
18 | {
19 | "destructuring": "all",
20 | "ignoreReadBeforeAssign": false
21 | }
22 | ]
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/.github/workflows/update.yml:
--------------------------------------------------------------------------------
1 | name: Deploy new artbot version
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | permissions:
7 | contents: write
8 | pull-requests: read
9 |
10 | jobs:
11 | build-n-deploy:
12 | name: Artbot new release
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: "✔️ Checkout"
16 | uses: actions/checkout@v3
17 | with:
18 | fetch-depth: 0
19 | - name: Run playbook
20 | uses: dawidd6/action-ansible-playbook@v2
21 | with:
22 | # Required, playbook filepath
23 | playbook: ansible/artbot_update.yml -e artbot_status_api="${{ secrets.ARTBOT_STATUS_API }}"
24 | # Optional, directory where playbooks live
25 | directory: ./
26 | # Optional, SSH private key
27 | key: ${{secrets.SSH_PRIVATE_KEY}}
28 | # Optional, SSH known hosts file content
29 | known_hosts: |
30 | 184.174.32.118 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKhA3ORj5KS0aMO9o5hsehVhaCN7akSHg91mjodMNag+
31 | 84.46.246.103 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMqXjtO3PXI3aWerxeR6WJFyAJgTO0UmIBljaCAn/Ypp
32 | # # Optional, encrypted vault password
33 | # vault_password: ${{secrets.VAULT_PASSWORD}}
34 | # Optional, galaxy requirements filepath
35 | requirements: ansible/galaxy-requirements.yml
36 | # Optional, additional flags to pass to ansible-playbook
37 | options: |
38 | --inventory ansible/inventory.yml
39 | -t update
--------------------------------------------------------------------------------
/.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 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 | build.sh
19 | *.tar.gz
20 |
21 | # misc
22 | .DS_Store
23 | *.pem
24 |
25 | # debug
26 | npm-debug.log*
27 | yarn-debug.log*
28 | yarn-error.log*
29 |
30 | # local env files
31 | .env
32 | .env*.local
33 |
34 | # vercel
35 | .vercel
36 |
37 | # typescript
38 | *.tsbuildinfo
39 | next-env.d.ts
40 |
41 | # Serwist
42 | public/sw*
43 | public/swe-worker*
44 |
45 | # Don't ignore buildId.json
46 | !buildId.json
47 | .aider*
48 | .claude/
49 |
--------------------------------------------------------------------------------
/.husky/.lintstagedrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | '*.{ts,tsx,js}': ['eslint --fix', 'prettier --write'],
3 | '*.{md,json}': 'prettier --write'
4 | }
5 |
--------------------------------------------------------------------------------
/.husky/post-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # If in a rebase, skip this hook
4 | if [ -d "$(git rev-parse --git-path rebase-merge)" ] || [ -d "$(git rev-parse --git-path rebase-apply)" ]; then
5 | echo "Rebase in progress — skipping post-commit hook."
6 | exit 0
7 | fi
8 |
9 | # If this is a recursive call, exit
10 | if [ -n "$HUSKY_SKIP_BUILDID" ]; then
11 | exit 0
12 | fi
13 |
14 | echo "Post-commit hook is running!"
15 |
16 | # Set the environment variable before running the commands
17 | export HUSKY_SKIP_BUILDID=1
18 |
19 | node generateBuildId.js
20 | git add buildId.json
21 | git commit --amend --no-edit --no-verify
22 |
23 | # Unset the variable (though not strictly necessary as the script is ending)
24 | unset HUSKY_SKIP_BUILDID
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 |
2 | cd "$(git rev-parse --show-toplevel)"
3 |
4 | npx lint-staged
5 | npx eslint . --ext .ts,.tsx
6 | npm test
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .next
3 | public
4 | coverage
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "bracketSpacing": true,
3 | "printWidth": 80,
4 | "semi": true,
5 | "singleQuote": true,
6 | "tabWidth": 2,
7 | "trailingComma": "none",
8 | "useTabs": false
9 | }
10 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # ArtBot Development Guide
2 |
3 | ## Commands
4 | - **Dev**: `npm run dev` - Start development server
5 | - **Build**: `npm run build && npm run postbuild` - Build for production
6 | - **Lint**: `npm run lint` - Run ESLint
7 | - **Test**: `npm test` - Run all tests
8 | - **Test Single File**: `npx jest path/to/file.test.ts --watch` - Run specific test
9 | - **Format Code**: `npm run prettier` - Format code with Prettier
10 |
11 | ## Code Style
12 | - **TypeScript**: Use strict typing; enable `noUnusedLocals` and `noUnusedParameters`; never use `any` as a valid type
13 | - **Imports**: Order: 1) React/Next.js, 2) External libraries, 3) Project modules
14 | - **Components**: Use functional components with hooks
15 | - **Error Handling**: Use try/catch for async operations; properly type errors
16 | - **Naming**: PascalCase for components; camelCase for functions/variables
17 | - **CSS**: Use Tailwind and CSS modules (*.module.css)
18 | - **State Management**: Use Statery for global state
19 | - **Testing**: Test business logic and component interactions
20 |
21 | ## Project Structure
22 | - `app/` - Next.js app directory with route-based components
23 | - `app/_components/` - Reusable UI components
24 | - `app/_hooks/` - Custom React hooks
25 | - `app/_utils/` - Helper functions and utilities
26 | - `app/_stores/` - Statery state stores
27 | - `app/_data-models/` - Data models and types
28 |
--------------------------------------------------------------------------------
/__mocks__/nanoid.js:
--------------------------------------------------------------------------------
1 | const object = {
2 | nanoid: () => '123',
3 | }
4 |
5 | module.exports = object
--------------------------------------------------------------------------------
/ansible/artbot_update.yml:
--------------------------------------------------------------------------------
1 | ---
2 |
3 | - hosts: artbot_servers
4 | gather_facts: false
5 |
6 | roles:
7 | - name: haidra.deployments.artbot
8 | tags: artbot
9 |
--------------------------------------------------------------------------------
/ansible/galaxy-requirements.yml:
--------------------------------------------------------------------------------
1 | ---
2 |
3 | collections:
4 | - git+https://github.com/Haidra-Org/deployments
--------------------------------------------------------------------------------
/ansible/inventory.yml:
--------------------------------------------------------------------------------
1 | ---
2 |
3 | all:
4 | hosts:
5 | artbot_server01:
6 | ansible_host: 184.174.32.118
7 | ansible_become: false
8 | ansible_user: artbot
9 | artbot_server02:
10 | ansible_host: 84.46.246.103
11 | ansible_become: false
12 | ansible_user: artbot
13 | children:
14 | artbot_servers:
15 | hosts:
16 | artbot_server01:
17 | artbot_server02:
--------------------------------------------------------------------------------
/app/(content)/about/page.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | import TotalImagesGenerated from '@/app/_components/TotalImagesGenerated/TotalImagesGenerated';
3 | import Linker from '../../_components/Linker';
4 | import PageTitle from '../../_components/PageTitle';
5 | import { appBasepath } from '../../_utils/browserUtils';
6 |
7 | export default function AboutPage() {
8 | return (
9 |
10 |
About ArtBot
11 |
12 |
22 |
23 |
24 |
25 | ArtBot is a front-end web client designed for interacting with the{' '}
26 | AI Horde distributed
27 | cluster.
28 |
29 |
30 | The AI Horde is an open source platform that utilizes idle GPU power
31 | provided by a community of generous users that allows anyone to create
32 | generative AI artwork on their own computers or mobile devices. More
33 | information is available on the Stable Horde page and you can also
34 | join their Discord server for further discussion on the technology
35 | behind the cluster, as well as tools built on top of the platform
36 | (such as ArtBot).
37 |
38 |
39 | ArtBot was initially built as a way to experiment with various
40 | client-side technology, such as IndexedDB and LocalStorage APIs. These
41 | APIs allow you to securely and privately store the AI generated images
42 | you've created with the cluster within your own browser. The UI
43 | components are built using NextJS. The source code is available on{' '}
44 | Github .
45 |
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/app/(content)/changelog/[page]/_component/ChangelogEntry.tsx:
--------------------------------------------------------------------------------
1 | // app/changelog/_components/ChangelogEntry.tsx
2 | 'use client'
3 |
4 | import ReactMarkdown from 'react-markdown'
5 | import Linker from '@/app/_components/Linker'
6 |
7 | interface ChangelogEntryProps {
8 | content: string
9 | }
10 |
11 | export default function ChangelogEntry({ content }: ChangelogEntryProps) {
12 | return (
13 |
14 |
,
17 | ul: (props) => ,
18 | // @ts-expect-error blah!
19 | a: (props) =>
20 | }}
21 | >
22 | {content}
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/app/(content)/changelog/_updates/2024.09.06.md:
--------------------------------------------------------------------------------
1 | # 2024.09.12 (v2.0.6-beta)
2 |
3 | - (fix) Update default TurboMix LoRA due to change in default sampler (k_euler_a)
4 | - (fix) Padding issues with footer
5 | - (feat) Add build ID to footer
6 | - (feat) Update "Made with" message in footer to use random rotating emoji
7 | - (fix) disable automated settings adjustment when choosing various models
8 | - (chore) integrate React Testing Library to begin writing some more robut tests for various interactive components
9 | - (refactor) whole bunch of refactoring to split some business logic from prentation logic in model select components, preset components
10 | - (feat) add ability to set models or presets via a query parameter (e.g., "/create?model=Dreamshaper" or "/create?preset=flux")
11 | - (chore) implement image counter service from ArtBot_v1 (no tracking of user info -- just like ArtBot_v1, when it image is finished, it calls an API simply says "hey, add +1 to your completed images counter")
12 |
--------------------------------------------------------------------------------
/app/(content)/changelog/_updates/2024.11.01.md:
--------------------------------------------------------------------------------
1 | # 2024.09.12 (v2.0.7-beta)
2 |
3 | - (chore) update NextJS depenencies
4 | - (fix) Saved user input in indexedDb so we can reload it between page loads / sessions.
5 |
--------------------------------------------------------------------------------
/app/(content)/changelog/page.tsx:
--------------------------------------------------------------------------------
1 | // app/changelog/page.tsx
2 | import { redirect } from 'next/navigation'
3 |
4 | export default function ChangelogIndex() {
5 | redirect('/changelog/1')
6 | }
7 |
--------------------------------------------------------------------------------
/app/(content)/create/_component/CustomQueryParamsHandler.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useCustomQueryParams } from '@/app/_hooks/useCustomQueryParams';
3 | import { useInput } from '@/app/_providers/PromptInputProvider';
4 |
5 | // It doesn't render anything, it just handles the query params
6 | export default function CustomQueryParamsHandler() {
7 | const { setInput } = useInput(); // Get setInput from the context
8 | useCustomQueryParams(setInput);
9 | return null;
10 | }
11 |
--------------------------------------------------------------------------------
/app/(content)/create/_component/PromptStickyCreate.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect, useState } from 'react'
4 | import PromptActionPanel from './PromptActionPanel'
5 |
6 | export default function PromptStickyCreate() {
7 | const [isSticky, setIsSticky] = useState(false)
8 |
9 | const handleScroll = () => {
10 | const offset = window.scrollY
11 | const stickyThreshold = 100 // adjust this value as needed
12 | setIsSticky(offset > stickyThreshold)
13 | }
14 |
15 | useEffect(() => {
16 | window.addEventListener('scroll', handleScroll)
17 | return () => {
18 | window.removeEventListener('scroll', handleScroll)
19 | }
20 | }, [])
21 |
22 | return (
23 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/app/(content)/create/json/dirty-json.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'dirty-json'
--------------------------------------------------------------------------------
/app/(content)/create/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import PageTitle from '../../_components/PageTitle';
3 | import { PromptInputProvider } from '../../_providers/PromptInputProvider';
4 | import PromptInputForm from './_component/PromptInputForm';
5 | import PromptActionPanel from './_component/PromptActionPanel';
6 | import PendingImagesPanel from '../../_components/PendingImagesPanel';
7 | import AdvancedOptions from '../../_components/AdvancedOptions';
8 | import PromptStickyCreate from './_component/PromptStickyCreate';
9 | import { Suspense } from 'react';
10 | import CustomQueryParamsHandler from './_component/CustomQueryParamsHandler';
11 |
12 | export const metadata: Metadata = {
13 | title: 'Create | ArtBot for Stable Diffusion'
14 | };
15 |
16 | // TODO: Figure out why I need to overiride Tailwind functions using "!".
17 | export default function CreatePage() {
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
25 |
Create
26 |
27 |
28 |
29 |
30 |
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/app/(content)/faq/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import PageTitle from '../../_components/PageTitle';
3 | import { FaqApiKey, FaqKudos } from './_component/FAQ_Kudos';
4 |
5 | export const metadata: Metadata = {
6 | title: 'FAQ | ArtBot for Stable Diffusion'
7 | };
8 |
9 | export default function FAQPage() {
10 | return (
11 |
12 |
FAQ
13 |
14 |
21 |
22 |
23 |
30 |
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/app/(content)/images/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import PageTitle from '../../_components/PageTitle';
3 | import Gallery from '../../_components/Gallery';
4 | import { Suspense } from 'react';
5 |
6 | export const metadata: Metadata = {
7 | title: 'Images | ArtBot for Stable Diffusion'
8 | };
9 |
10 | export default async function ImagesPage() {
11 | return (
12 |
13 |
Images
14 |
15 |
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/app/(content)/info/models/page.tsx:
--------------------------------------------------------------------------------
1 | import PageTitle from '@/app/_components/PageTitle'
2 | import ModelsInfo from './_component/ModelsInfo'
3 | import { getModelsData } from '@/app/_api/models'
4 |
5 | export default async function ModelsPage() {
6 | const { modelsAvailable, modelDetails } = await getModelsData()
7 |
8 | return (
9 |
10 |
Model Details
11 |
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/app/(content)/info/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import PageTitle from '../../_components/PageTitle';
3 |
4 | export const metadata: Metadata = {
5 | title: 'Info | ArtBot for Stable Diffusion'
6 | };
7 |
8 | export default async function InfoPage() {
9 | return (
10 |
11 |
Info
12 | Placeholder for Info Page
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/app/(content)/pending/page.tsx:
--------------------------------------------------------------------------------
1 | import PageTitle from '../../_components/PageTitle';
2 | import PendingImagesPanel from '../../_components/PendingImagesPanel';
3 |
4 | export default function PendingPage() {
5 | return (
6 |
7 |
Pending Images
8 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/app/(content)/privacy/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import PageTitle from '../../_components/PageTitle';
3 |
4 | export const metadata: Metadata = {
5 | title: 'Privacy Policy | ArtBot for Stable Diffusion'
6 | };
7 |
8 | async function getData() {
9 | const res = await fetch('https://aihorde.net/api/v2/documents/privacy');
10 | const data = res.json();
11 |
12 | return data;
13 | }
14 |
15 | export default async function PrivacyPage() {
16 | const data = await getData();
17 |
18 | data.html = data.html.replace('Privacy Policy \n', '');
19 | data.html = data.html.replace(//g, '');
20 | data.html = data.html.replace(
21 | //g,
22 | ''
23 | );
24 | data.html = data.html.replace(//g, '');
25 |
26 | return (
27 |
28 |
Privacy Policy
29 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/app/(content)/settings/_component/AddEditSharedKey.tsx:
--------------------------------------------------------------------------------
1 | import Input from '@/app/_components/Input'
2 | import Button from '@/app/_components/Button'
3 | import { useEffect, useState } from 'react'
4 | import { SharedApiKey } from '@/app/_types/HordeTypes'
5 |
6 | export default function AddEditSharedKey({
7 | buttonText = 'Create',
8 | onCreateClick = () => {},
9 | sharedKey
10 | }: {
11 | buttonText?: 'Create' | 'Update'
12 | // @ts-expect-error TODO: Handle types
13 | onCreateClick?: ({ id, name, kudos }) => void
14 | sharedKey?: SharedApiKey
15 | }) {
16 | const [pending, setPending] = useState(false)
17 | const [inputKeyId, setInputKeyId] = useState('')
18 | const [inputKeyName, setInputKeyName] = useState('')
19 | const [inputKeyKudos, setInputKeyKudos] = useState('')
20 |
21 | useEffect(() => {
22 | if (sharedKey) {
23 | setInputKeyId(sharedKey.id)
24 | setInputKeyName(sharedKey.name)
25 | setInputKeyKudos(sharedKey.kudos.toString())
26 | }
27 | }, [sharedKey])
28 |
29 | return (
30 |
31 |
Create shared API key
32 |
33 | Shared key name:
34 | ) =>
38 | setInputKeyName(event.target.value)
39 | }
40 | />
41 |
42 |
43 | Total kudos:
44 | ) =>
48 | setInputKeyKudos(event.target.value)
49 | }
50 | />
51 |
52 |
53 | {/* setShowCreateModal(false)} theme="secondary">
54 | Cancel
55 | */}
56 | {
59 | setPending(true)
60 |
61 | onCreateClick({
62 | id: inputKeyId,
63 | name: inputKeyName,
64 | kudos: inputKeyKudos
65 | })
66 | }}
67 | >
68 | {pending ? 'Requesting...' : buttonText}
69 |
70 |
71 |
72 | )
73 | }
74 |
--------------------------------------------------------------------------------
/app/(content)/settings/_component/AddEditWebhook.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import Input from '@/app/_components/Input';
4 | import Button from '@/app/_components/Button';
5 |
6 | interface AddEditWebhookProps {
7 | handleAddWebhookUrl: ({ name, url }: { name: string; url: string }) => void;
8 | }
9 |
10 | export default function AddEditWebhook({
11 | handleAddWebhookUrl
12 | }: AddEditWebhookProps) {
13 | const [inputName, setInputName] = useState('');
14 | const [inputUrl, setInputUrl] = useState('');
15 |
16 | return (
17 |
18 |
Add webhook URL
19 |
20 | Webhook Name:
21 | setInputName(e.target.value)}
25 | />
26 |
27 |
28 | Webhook URL:
29 | setInputUrl(e.target.value)}
33 | />
34 |
35 |
{
37 | handleAddWebhookUrl({ name: inputName, url: inputUrl });
38 | }}
39 | >
40 | Add Webhook
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/app/(content)/settings/_component/GoogleAuth.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Button from '@/app/_components/Button';
4 | import Section from '../../../_components/Section';
5 | import { useGoogleAuth } from '@/app/_hooks/useGoogleAuth';
6 | import { IconBrandGoogleDrive, IconLogout2 } from '@tabler/icons-react';
7 |
8 | export default function GoogleAuth() {
9 | const { authState, error, handleSignIn, handleSignOut } = useGoogleAuth();
10 |
11 | return (
12 |
13 |
14 | {error &&
Error: {error}
}
15 | {!authState.isSignedIn ? (
16 |
21 |
22 | Connect Google Account
23 |
24 |
25 | ) : (
26 |
27 |
32 |
33 | Unlink Google Account
34 |
35 |
36 |
37 | )}
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/app/(content)/settings/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import PageTitle from '../../_components/PageTitle';
3 | import Apikey from './_component/Apikey';
4 | import WorkerList from './_component/WorkerList';
5 | import SharedKeys from './_component/SharedKeys';
6 | import SectionTitle from '../../_components/SectionTitle';
7 | import Section from '../../_components/Section';
8 | import Linker from '../../_components/Linker';
9 | import WebhookUrls from './_component/WebhookUrls';
10 | import GoogleAuth from './_component/GoogleAuth';
11 |
12 | export const metadata: Metadata = {
13 | title: 'Settings | ArtBot for Stable Diffusion'
14 | };
15 |
16 | export default async function SettingsPage() {
17 | return (
18 |
19 |
Settings
20 |
21 |
22 |
API Keys
23 |
24 |
25 |
26 |
27 |
Workers
28 |
29 |
30 |
31 |
32 | Visit the{' '}
33 |
34 | worker management page
35 | {' '}
36 | to manage your workers.
37 |
38 |
39 |
40 |
41 | Connections
42 |
43 |
44 |
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/app/(content)/settings/workers/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import MyWorkerSummary from '@/app/_components/MyWorkerSummary';
4 | import PageTitle from '@/app/_components/PageTitle';
5 | import useMyWorkerDetails from '@/app/_hooks/useMyWorkerDetails';
6 | import { useEffect } from 'react';
7 |
8 | export default function WorkersPage() {
9 | const { fetchAllWorkersDetails, workersDetails, worker_ids } =
10 | useMyWorkerDetails();
11 |
12 | useEffect(() => {
13 | fetchAllWorkersDetails();
14 | }, [fetchAllWorkersDetails]);
15 |
16 | return (
17 |
18 |
Manage Workers
19 |
20 | Please note, it can take up to 5 minutes before the changes are
21 | reflected in the worker list.
22 |
23 |
24 | {(!workersDetails ||
25 | (workersDetails?.length === 0 && worker_ids?.length === 0)) && (
26 |
You have no active GPU workers.
27 | )}
28 | {workersDetails?.length === 0 &&
29 | worker_ids &&
30 | worker_ids?.length > 0 && (
31 |
32 |
33 | Loading worker details...
34 |
35 | )}
36 | {worker_ids && worker_ids?.length > 0 && (
37 |
38 | {workersDetails.map((worker) => {
39 | return ;
40 | })}
41 |
42 | )}
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/app/(content)/terms/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import PageTitle from '../../_components/PageTitle';
3 |
4 | export const metadata: Metadata = {
5 | title: 'Terms and Conditions | ArtBot for Stable Diffusion'
6 | };
7 |
8 | async function getData() {
9 | const res = await fetch('https://aihorde.net/api/v2/documents/terms');
10 | const data = res.json();
11 |
12 | return data;
13 | }
14 |
15 | export default async function TermsPage() {
16 | const data = await getData();
17 |
18 | data.html = data.html.replace('Terms and Conditions \n', '');
19 | data.html = data.html.replace(//g, '');
20 | data.html = data.html.replace(
21 | //g,
22 | ''
23 | );
24 | data.html = data.html.replace(//g, '');
25 |
26 | return (
27 |
28 |
Terms and Conditions
29 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/app/(content)/user/page.tsx:
--------------------------------------------------------------------------------
1 | import PageTitle from '@/app/_components/PageTitle';
2 |
3 | export default async function UserProfilePage() {
4 | return (
5 |
6 |
User Profile
7 |
Nothing to see here yet.
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/app/(home)/not-found.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | import PageTitle from '../_components/PageTitle';
3 | import { appBasepath } from '../_utils/browserUtils';
4 |
5 | export default function NotFoundPage() {
6 | return (
7 |
8 |
404 Error | Nothing to see here
9 |
10 |
20 |
21 |
Oh, no! This is unfortunate. It appears there is nothing here.
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/app/_api/artbot/debugSaveResponse.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 |
3 | /**
4 | * Saves API response data for debugging purposes on a local machine.
5 | *
6 | * This function sends a POST request to a local debug endpoint ('/api/debug/save-response')
7 | * with the provided API response data. It's designed to be attached to API requests
8 | * to log data for debugging and troubleshooting.
9 | *
10 | * @param id - A unique identifier for the API response
11 | * @param data - The API response data to be saved (can be of any type)
12 | * @param route - The API route that was called
13 | *
14 | * @example
15 | * // Usage in an API call:
16 | * const apiData = await fetchSomeApiData();
17 | * await debugSaveApiResponse('uniqueId123', apiData, '/api/some-endpoint');
18 | */
19 | export const debugSaveApiResponse = async (
20 | id: string,
21 | data: any,
22 | route: string
23 | ) => {
24 | if (process.env.NEXT_PUBLIC_SAVE_DEBUG_LOGS !== 'true') return
25 |
26 | try {
27 | const response = await fetch('/api/debug/save-response', {
28 | method: 'POST',
29 | headers: {
30 | 'Content-Type': 'application/json'
31 | },
32 | body: JSON.stringify({ id, data, route })
33 | })
34 |
35 | if (!response.ok) {
36 | throw new Error('Failed to save API response')
37 | }
38 |
39 | console.log('API response saved successfully')
40 | } catch (error) {
41 | console.error('Error saving API response:', error)
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/app/_api/horde/check_webworker.ts:
--------------------------------------------------------------------------------
1 | self.onmessage = async (event) => {
2 | const { jobId, url, headers } = event.data
3 |
4 | try {
5 | // Add AbortController for timeout
6 | const controller = new AbortController()
7 | const timeoutId = setTimeout(() => controller.abort(), 25000) // 25s timeout (less than TaskQueue's 30s)
8 |
9 | const res = await fetch(url, {
10 | headers,
11 | cache: 'no-store',
12 | signal: controller.signal
13 | })
14 | clearTimeout(timeoutId)
15 |
16 | const statusCode = res.status
17 | const data = await res.json()
18 |
19 | if ('done' in data && 'is_possible' in data) {
20 | self.postMessage({
21 | jobId,
22 | result: {
23 | success: true,
24 | ...data
25 | }
26 | })
27 | } else {
28 | self.postMessage({
29 | jobId,
30 | result: {
31 | success: false,
32 | message: data.message,
33 | statusCode
34 | }
35 | })
36 | }
37 | } catch (error) {
38 | // Better error handling with specific abort error
39 | let message = 'unknown error'
40 | let statusCode = 0
41 |
42 | if (error instanceof Error) {
43 | if (error.name === 'AbortError') {
44 | message = 'Request timed out after 25 seconds'
45 | statusCode = 408 // Request Timeout
46 | } else {
47 | message = error.message
48 | }
49 | }
50 |
51 | self.postMessage({
52 | jobId,
53 | result: {
54 | success: false,
55 | statusCode,
56 | message
57 | }
58 | })
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/app/_api/horde/download.ts:
--------------------------------------------------------------------------------
1 | // Not really a Horde specific API endpoint, but this
2 | // downloads images returned from the Horde.
3 |
4 | import { ImageBlobBuffer } from '@/app/_data-models/ImageFile_Dexie'
5 | import { blobToArrayBuffer } from '@/app/_utils/imageUtils'
6 |
7 | export interface DownloadSuccessResponse {
8 | success: boolean
9 | blobBuffer: ImageBlobBuffer
10 | }
11 | export interface DownloadErrorResponse {
12 | success: boolean
13 | statusCode: number
14 | message: string
15 | details?: string
16 | }
17 |
18 | /**
19 | * Downloads an image from a remote API endpoint.
20 | *
21 | * @param imgUrl - The URL of the image to download.
22 | * @returns A promise that resolves to a DownloadSuccessResponse on success
23 | * or a DownloadErrorResponse on failure.
24 | */
25 | export default async function downloadImage(
26 | imgUrl: string
27 | ): Promise {
28 | try {
29 | const imageData = await fetch(imgUrl)
30 |
31 | if (!imageData.ok) {
32 | return {
33 | success: false,
34 | statusCode: imageData.status,
35 | message: `http error ${imageData.status}`,
36 | details: `HTTP error: ${imageData.status} - ${imageData.statusText}`
37 | }
38 | }
39 |
40 | const blob = await imageData.blob()
41 | const blobBuffer = await blobToArrayBuffer(blob)
42 |
43 | return {
44 | success: true,
45 | blobBuffer
46 | }
47 | } catch (err) {
48 | const error = err as Error
49 | console.log(`Error attempting to download image: ${imgUrl}`)
50 | console.log(err)
51 | return {
52 | success: false,
53 | statusCode: 0,
54 | message: 'unknown error',
55 | details: error.message || error.toString()
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/app/_api/horde/heartbeat.ts:
--------------------------------------------------------------------------------
1 | export default async function hordeHeartbeat(): Promise {
2 | try {
3 | const controller = new AbortController();
4 | const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
5 |
6 | const response = await fetch(
7 | 'https://aihorde.net/api/v2/status/heartbeat',
8 | {
9 | signal: controller.signal
10 | }
11 | );
12 |
13 | clearTimeout(timeoutId);
14 |
15 | return response.ok;
16 | } catch (error) {
17 | // Return false if there's a timeout, network error, or any other issue
18 | return false;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/app/_api/horde/messages.ts:
--------------------------------------------------------------------------------
1 | import { AppConstants } from '@/app/_data-models/AppConstants';
2 | import { AppSettings } from '@/app/_data-models/AppSettings';
3 | import { clientHeader } from '@/app/_data-models/ClientHeader';
4 | import { updateHordeMessages } from '@/app/_stores/UserStore';
5 | import { WorkerMessage } from '@/app/_types/HordeTypes';
6 |
7 | export const getWorkerMessages = async () => {
8 | try {
9 | const response = await fetch(
10 | `${AppConstants.AI_HORDE_PROD_URL}/api/v2/workers/messages`,
11 | {
12 | headers: {
13 | apikey: AppSettings.get('apiKey'),
14 | 'Content-Type': 'application/json',
15 | 'Client-Agent': clientHeader()
16 | }
17 | }
18 | );
19 |
20 | const data: WorkerMessage[] = await response.json();
21 | updateHordeMessages(data);
22 | } catch (error) {
23 | console.error('Error fetching messages:', error);
24 | }
25 | };
26 |
--------------------------------------------------------------------------------
/app/_api/horde/rateLimiter.ts:
--------------------------------------------------------------------------------
1 | // Shared rate limiter for Horde API calls
2 | class RateLimiter {
3 | private requestTimes: number[] = [];
4 | private readonly maxRequests: number;
5 | private readonly windowMs: number;
6 |
7 | constructor(maxRequests: number, windowMs: number) {
8 | this.maxRequests = maxRequests;
9 | this.windowMs = windowMs;
10 | }
11 |
12 | async waitForSlot(): Promise {
13 | const now = Date.now();
14 | // Remove old entries outside the window
15 | this.requestTimes = this.requestTimes.filter(time => now - time < this.windowMs);
16 |
17 | if (this.requestTimes.length >= this.maxRequests) {
18 | // Wait until the oldest request is outside the window
19 | const oldestRequest = this.requestTimes[0];
20 | const waitTime = this.windowMs - (now - oldestRequest) + 10; // +10ms buffer
21 | if (waitTime > 0) {
22 | await new Promise(resolve => setTimeout(resolve, waitTime));
23 | }
24 | // Recursive call to check again
25 | return this.waitForSlot();
26 | }
27 |
28 | this.requestTimes.push(now);
29 | }
30 | }
31 |
32 | // Different rate limiters for different endpoint types
33 |
34 | // /generate/async endpoint - shares the same limit as /status (10 per minute)
35 | // Using 9 per minute to have a safety buffer
36 | export const generateRateLimiter = new RateLimiter(9, 60000);
37 |
38 | // /generate/check endpoint - 1 request per 2 seconds (30 per minute)
39 | // Using 1 per 2.1 seconds for safety buffer
40 | export const checkRateLimiter = new RateLimiter(1, 2100);
41 |
42 | // /generate/status endpoint - 10 requests per minute
43 | // Using 9 per minute to have a safety buffer
44 | export const statusRateLimiter = new RateLimiter(9, 60000);
--------------------------------------------------------------------------------
/app/_api/horde/status.ts:
--------------------------------------------------------------------------------
1 | import { AppConstants } from '@/app/_data-models/AppConstants'
2 | import { clientHeader } from '@/app/_data-models/ClientHeader'
3 | import { HordeJobResponse } from '@/app/_types/HordeTypes'
4 | import { debugSaveApiResponse } from '../artbot/debugSaveResponse'
5 | import { statusRateLimiter } from './rateLimiter'
6 |
7 | interface HordeErrorResponse {
8 | message: string
9 | }
10 |
11 | export interface StatusSuccessResponse extends HordeJobResponse {
12 | success: boolean
13 | message?: string
14 | }
15 |
16 | export interface StatusErrorResponse extends HordeErrorResponse {
17 | success: boolean
18 | statusCode: number
19 | }
20 |
21 | export default async function imageStatus(
22 | jobId: string
23 | ): Promise {
24 | let statusCode
25 |
26 | if (!jobId) {
27 | return {
28 | success: false,
29 | statusCode: 400,
30 | message: 'jobId is required'
31 | }
32 | }
33 |
34 | // Wait for rate limit slot
35 | await statusRateLimiter.waitForSlot();
36 |
37 | try {
38 | const res = await fetch(
39 | `${AppConstants.AI_HORDE_PROD_URL}/api/v2/generate/status/${jobId}`,
40 | {
41 | cache: 'no-store',
42 | headers: {
43 | 'Content-Type': 'application/json',
44 | 'Client-Agent': clientHeader()
45 | }
46 | }
47 | )
48 |
49 | statusCode = res.status
50 | const data: HordeJobResponse | HordeErrorResponse = await res.json()
51 |
52 | if ('done' in data) {
53 | await debugSaveApiResponse(jobId, data, `/api/v2/generate/check/${jobId}`)
54 |
55 | return {
56 | success: true,
57 | ...data
58 | }
59 | } else {
60 | return {
61 | success: false,
62 | statusCode,
63 | ...data
64 | }
65 | }
66 | } catch (err) {
67 | console.log(`Error: Unable to download images for jobId: ${jobId}`)
68 | console.log(err)
69 |
70 | return {
71 | success: false,
72 | statusCode: statusCode ?? 0,
73 | message: 'unknown error'
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/app/_api/horde/workers.ts:
--------------------------------------------------------------------------------
1 | import { AppConstants } from '@/app/_data-models/AppConstants'
2 | import { clientHeader } from '@/app/_data-models/ClientHeader'
3 | import { HordeWorker } from '@/app/_types/HordeTypes'
4 |
5 | // Cache and timestamp initialization
6 | let workersCache: HordeWorker[] = []
7 | let lastFetchTime = 0
8 |
9 | export const fetchHordeWorkers = async () => {
10 | const currentTime = new Date().getTime()
11 |
12 | // Check if the last fetch was less than a minute ago
13 | if (currentTime - lastFetchTime < 60000 && workersCache.length > 0) {
14 | return workersCache // Return the cached data
15 | }
16 |
17 | try {
18 | const res = await fetch(
19 | `${AppConstants.AI_HORDE_PROD_URL}/api/v2/workers`,
20 | {
21 | cache: 'no-store',
22 | headers: {
23 | 'Content-Type': 'application/json',
24 | 'Client-Agent': clientHeader()
25 | },
26 | method: 'GET'
27 | }
28 | )
29 |
30 | const data = await res.json()
31 |
32 | if (Array.isArray(data)) {
33 | // Filter for image workers only:
34 | const filtered = data.filter((worker) => worker.type === 'image')
35 |
36 | // Update the cache and timestamp
37 | workersCache = filtered
38 | lastFetchTime = currentTime
39 | } else {
40 | if (workersCache.length > 0) {
41 | return workersCache
42 | }
43 |
44 | return []
45 | }
46 | } catch (err) {
47 | console.log(`Error: Unable to fetch worker details from AI Horde`)
48 | console.log(err)
49 |
50 | if (workersCache.length > 0) {
51 | return workersCache
52 | }
53 |
54 | return []
55 | }
56 |
57 | // We should probably never get here, yeah?
58 | return workersCache
59 | }
60 |
--------------------------------------------------------------------------------
/app/_api/models/index.tsx:
--------------------------------------------------------------------------------
1 | import { clientHeader } from '@/app/_data-models/ClientHeader'
2 | import { AvailableImageModel } from '@/app/_types/HordeTypes'
3 |
4 | export async function getModelsData() {
5 | try {
6 | const availableUrl = `https://aihorde.net/api/v2/status/models`
7 | const detailsUrl = `https://raw.githubusercontent.com/Haidra-Org/AI-Horde-image-model-reference/main/stable_diffusion.json`
8 |
9 | const [availableRes, detailsRes] = await Promise.allSettled([
10 | fetch(availableUrl, {
11 | headers: {
12 | 'Client-Agent': clientHeader(),
13 | 'Content-Type': 'application/json'
14 | },
15 | next: { revalidate: 120 } // Revalidate every 120 seconds
16 | }),
17 | fetch(detailsUrl, {
18 | cache: 'no-store',
19 | headers: {
20 | 'Content-Type': 'application/json'
21 | },
22 | method: 'GET'
23 | })
24 | ])
25 |
26 | const availableData =
27 | availableRes.status === 'fulfilled' ? await availableRes.value.json() : []
28 | const detailsData =
29 | detailsRes.status === 'fulfilled' ? await detailsRes.value.json() : {}
30 |
31 | const availableFiltered = availableData
32 | .filter((model: AvailableImageModel) => model.type === 'image')
33 | .sort(
34 | (a: AvailableImageModel, b: AvailableImageModel) => b.count - a.count
35 | )
36 |
37 | // Parse json to ensure validity
38 | const jsonString = JSON.stringify(detailsData)
39 | const json = JSON.parse(jsonString)
40 |
41 | // Optional: Additional checks to ensure the JSON structure is as expected
42 | if (!json || typeof json !== 'object' || Object.keys(json).length === 0) {
43 | return {
44 | modelsAvailable: [],
45 | modelDetails: {}
46 | }
47 | }
48 |
49 | return {
50 | modelsAvailable: availableFiltered,
51 | modelDetails: detailsData
52 | }
53 | } catch (error) {
54 | return {
55 | modelsAvailable: [],
56 | modelDetails: {}
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/app/_api/presets/index.tsx:
--------------------------------------------------------------------------------
1 | // utils/fetchPresetData.ts
2 | import {
3 | CategoryPreset,
4 | StylePresetConfigurations,
5 | StylePreviewConfigurations
6 | } from '@/app/_types/HordeTypes';
7 |
8 | export async function getPresetData(): Promise<{
9 | success: boolean;
10 | categories: CategoryPreset;
11 | presets: StylePresetConfigurations;
12 | previews: StylePreviewConfigurations;
13 | }> {
14 | try {
15 | const urls = [
16 | 'https://raw.githubusercontent.com/Haidra-Org/AI-Horde-Styles/main/categories.json',
17 | 'https://raw.githubusercontent.com/Haidra-Org/AI-Horde-Styles/main/styles.json',
18 | 'https://raw.githubusercontent.com/amiantos/AI-Horde-Styles-Previews/main/previews.json'
19 | ];
20 |
21 | const [categoriesRes, presetsRes, previewsRes] = await Promise.allSettled(
22 | urls.map((url) => fetch(url))
23 | );
24 |
25 | const categories =
26 | categoriesRes.status === 'fulfilled'
27 | ? await categoriesRes.value.json()
28 | : {};
29 | const presets: StylePresetConfigurations =
30 | presetsRes.status === 'fulfilled' ? await presetsRes.value.json() : {};
31 | const previews: StylePreviewConfigurations =
32 | previewsRes.status === 'fulfilled' ? await previewsRes.value.json() : {};
33 |
34 | // Filter categories to include only those keys that exist in presets
35 | const filteredCategories = Object.keys(categories).reduce((acc, key) => {
36 | acc[key] = categories[key].filter(
37 | (category: string) => category in presets
38 | );
39 | return acc;
40 | }, {} as CategoryPreset);
41 |
42 | return {
43 | success: true,
44 | categories: filteredCategories,
45 | presets,
46 | previews
47 | };
48 | } catch (err) {
49 | console.error(err);
50 | return {
51 | success: false,
52 | categories: {},
53 | presets: {},
54 | previews: {}
55 | };
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/app/_components/AdvancedOptions/AdditionalOptions.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useInput } from '../../_providers/PromptInputProvider'
3 | import Section from '../Section'
4 | import Switch from '../Switch'
5 |
6 | export default function AdditionalOptions() {
7 | const { input, setInput } = useInput()
8 |
9 | return (
10 |
15 |
16 | {
19 | if (input.post_processing.includes('strip_background')) {
20 | setInput({
21 | post_processing: input.post_processing.filter(
22 | (value) => value !== 'strip_background'
23 | )
24 | })
25 | } else {
26 | setInput({
27 | post_processing: [...input.post_processing, 'strip_background']
28 | })
29 | }
30 | }}
31 | />
32 | Strip background
33 |
34 |
35 | {
38 | setInput({ karras: !input.karras })
39 | }}
40 | />
41 | Karras
42 |
43 |
44 | {
47 | setInput({ tiling: !input.tiling })
48 | }}
49 | />
50 | Tiling
51 |
52 |
53 | {
56 | setInput({ transparent: !input.transparent })
57 | }}
58 | />
59 | Transparent background
60 |
61 |
62 | )
63 | }
64 |
--------------------------------------------------------------------------------
/app/_components/AdvancedOptions/ClipSkip.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useInput } from '@/app/_providers/PromptInputProvider'
3 | import NumberInput from '../NumberInput'
4 | import OptionLabel from './OptionLabel'
5 |
6 | export default function ClipSkip() {
7 | const { input, setInput } = useInput()
8 |
9 | return (
10 |
14 | CLIP Skip
15 |
16 | }
17 | >
18 |
19 | {
23 | setInput({ clipskip: Number(num) as unknown as number })
24 | }}
25 | onMinusClick={() => {
26 | if (Number(input.clipskip) - 1 < 1) {
27 | return
28 | }
29 |
30 | setInput({ clipskip: Number(input.clipskip) - 1 })
31 | }}
32 | onPlusClick={() => {
33 | if (Number(input.clipskip) + 1 > 12) {
34 | return
35 | }
36 |
37 | setInput({ clipskip: Number(input.clipskip) + 1 })
38 | }}
39 | value={input.clipskip}
40 | />
41 |
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/app/_components/AdvancedOptions/Guidance.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useInput } from '@/app/_providers/PromptInputProvider'
3 | import NumberInput from '../NumberInput'
4 | import OptionLabel from './OptionLabel'
5 |
6 | export default function Guidance() {
7 | const { input, setInput } = useInput()
8 |
9 | return (
10 | Guidance
14 | }
15 | >
16 |
17 | {
21 | if (isNaN(input.cfg_scale)) {
22 | setInput({ cfg_scale: 7.5 })
23 | } else {
24 | setInput({
25 | cfg_scale: parseFloat(Number(input.cfg_scale).toFixed(1))
26 | })
27 | }
28 | }}
29 | onChange={(num) => {
30 | setInput({ cfg_scale: num as unknown as number })
31 | }}
32 | onMinusClick={() => {
33 | if (Number(input.cfg_scale) - 0.5 < 0.5) {
34 | return
35 | }
36 |
37 | setInput({ cfg_scale: Number(input.cfg_scale) - 0.5 })
38 | }}
39 | onPlusClick={() => {
40 | if (Number(input.cfg_scale) + 0.5 > 30) {
41 | return
42 | }
43 |
44 | setInput({ cfg_scale: Number(input.cfg_scale) + 0.5 })
45 | }}
46 | value={input.cfg_scale}
47 | />
48 |
49 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/app/_components/AdvancedOptions/ImageCount.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useInput } from '@/app/_providers/PromptInputProvider';
4 | import NumberInput from '../NumberInput';
5 | import OptionLabel from './OptionLabel';
6 |
7 | export default function ImageCount() {
8 | const { input, setInput } = useInput();
9 |
10 | return (
11 | Images
15 | }
16 | >
17 |
18 | {
22 | if (isNaN(input.numImages)) {
23 | setInput({ numImages: 1 });
24 | } else {
25 | setInput({
26 | numImages: parseFloat(Number(input.numImages).toFixed(0))
27 | });
28 | }
29 | }}
30 | onChange={(num) => {
31 | setInput({ numImages: num as unknown as number });
32 | }}
33 | onMinusClick={() => {
34 | if (Number(input.numImages) - 1 < 1) {
35 | return;
36 | }
37 |
38 | setInput({ numImages: Number(input.numImages) - 1 });
39 | }}
40 | onPlusClick={() => {
41 | if (Number(input.numImages) + 1 > 20) {
42 | return;
43 | }
44 |
45 | setInput({ numImages: Number(input.numImages) + 1 });
46 | }}
47 | value={input.numImages}
48 | />
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/app/_components/AdvancedOptions/LoRAs/loraSearch.module.css:
--------------------------------------------------------------------------------
1 | .image-item {
2 | cursor: pointer;
3 | position: relative;
4 | width: 100%;
5 | padding-top: 100%;
6 | overflow: hidden;
7 | }
8 |
9 | .image-item img {
10 | position: absolute;
11 | top: 0;
12 | left: 0;
13 | width: 100%;
14 | height: 100%;
15 | object-fit: cover;
16 | display: block;
17 | }
--------------------------------------------------------------------------------
/app/_components/AdvancedOptions/ModelSelect/index.tsx:
--------------------------------------------------------------------------------
1 | import { AvailableImageModel } from '@/app/_types/HordeTypes'
2 | import ModelSelectComponent from './modelSelectComponent'
3 | import models from './models.json'
4 | import { clientHeader } from '@/app/_data-models/ClientHeader'
5 |
6 | export async function getData() {
7 | try {
8 | if (process.env.NODE_ENV === 'development') {
9 | // Simple request so I don't hit a Horde rate limit
10 | console.log(`!! DEV MODE: Fetching models from local models.json file.`)
11 | const data = models
12 |
13 | const filtered = data
14 | .filter((model: AvailableImageModel) => model.type === 'image')
15 | .sort(
16 | (a: AvailableImageModel, b: AvailableImageModel) => b.count - a.count
17 | )
18 |
19 | return {
20 | success: true,
21 | models: filtered
22 | }
23 | }
24 |
25 | const res = await fetch(`https://aihorde.net/api/v2/status/models`, {
26 | headers: {
27 | // apikey: apikey,
28 | 'Client-Agent': clientHeader(),
29 | 'Content-Type': 'application/json'
30 | },
31 | next: { revalidate: 60 } // Revalidate every 60 seconds
32 | })
33 |
34 | const data = (await res.json()) || {}
35 |
36 | console.log('data?', data)
37 |
38 | const filtered = data
39 | .filter((model: AvailableImageModel) => model.type === 'image')
40 | .sort(
41 | (a: AvailableImageModel, b: AvailableImageModel) => b.count - a.count
42 | )
43 |
44 | return {
45 | success: true,
46 | models: filtered
47 | }
48 | } catch (err) {
49 | console.log(err)
50 | return {
51 | success: false,
52 | models: []
53 | }
54 | }
55 | }
56 |
57 | export default async function ModelSelect() {
58 | return (
59 |
61 | )
62 | }
63 |
--------------------------------------------------------------------------------
/app/_components/AdvancedOptions/ModelSelect/modalWrapper.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import { ModelStore } from '@/app/_stores/ModelStore';
3 | import ModelsInfo from '@/app/(content)/info/models/_component/ModelsInfo';
4 | import { useStore } from 'statery';
5 |
6 | interface Props {
7 | handleSelectModel: (model: string) => void;
8 | }
9 |
10 | const ModelModalWrapper = ({ handleSelectModel }: Props) => {
11 | const { availableModels, modelDetails } = useStore(ModelStore);
12 | const modalRef = useRef(null);
13 |
14 | useEffect(() => {
15 | if (modalRef.current) {
16 | setTimeout(() => {
17 | window.scrollTo(0, 0);
18 | }, 100);
19 | }
20 | }, []);
21 |
22 | return (
23 |
24 |
25 |
Image models
26 |
32 |
33 |
34 | );
35 | };
36 |
37 | export default ModelModalWrapper;
38 |
--------------------------------------------------------------------------------
/app/_components/AdvancedOptions/OptionLabel.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import { ReactNode } from 'react'
3 |
4 | export default function OptionLabel({
5 | anchor,
6 | children,
7 | className,
8 | title,
9 | minWidth = '100px'
10 | }: {
11 | anchor?: string
12 | children: ReactNode
13 | className?: string
14 | minWidth?: string
15 | title: ReactNode
16 | }) {
17 | return (
18 |
22 | {title}
23 | {children}
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/app/_components/AdvancedOptions/SamplerSelect.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useInput } from '@/app/_providers/PromptInputProvider'
4 | import Select, { SelectOption } from '../Select'
5 | import OptionLabel from './OptionLabel'
6 | import { SamplerOption } from '@/app/_types/HordeTypes'
7 |
8 | const samplers: Array<{ value: SamplerOption; label: SamplerOption }> = [
9 | { value: 'DDIM', label: 'DDIM' },
10 | { value: 'k_dpm_2_a', label: 'k_dpm_2_a' },
11 | { value: 'k_dpm_2', label: 'k_dpm_2' },
12 | { value: 'k_dpm_adaptive', label: 'k_dpm_adaptive' },
13 | { value: 'k_dpm_fast', label: 'k_dpm_fast' },
14 | { value: 'k_dpmpp_2m', label: 'k_dpmpp_2m' },
15 | { value: 'k_dpmpp_2s_a', label: 'k_dpmpp_2s_a' },
16 | { value: 'k_dpmpp_sde', label: 'k_dpmpp_sde' },
17 | { value: 'k_euler_a', label: 'k_euler_a' },
18 | { value: 'k_euler', label: 'k_euler' },
19 | { value: 'k_heun', label: 'k_heun' },
20 | { value: 'k_lms', label: 'k_lms' },
21 | { value: 'lcm', label: 'lcm' }
22 | ]
23 |
24 | export default function SamplerSelect() {
25 | const { input, setInput } = useInput()
26 |
27 | return (
28 | Sampler
31 | }
32 | >
33 |
34 | {
36 | setInput({ sampler: option.value as SamplerOption })
37 | }}
38 | options={samplers.map((sampler) => ({
39 | value: sampler.value,
40 | label: sampler.label
41 | }))}
42 | value={{
43 | value: input.sampler,
44 | label: input.sampler
45 | }}
46 | />
47 |
48 |
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/app/_components/AdvancedOptions/Seed.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useInput } from '@/app/_providers/PromptInputProvider';
4 | import OptionLabel from './OptionLabel';
5 | import Button from '../Button';
6 | import { IconArrowBarLeft, IconDice5 } from '@tabler/icons-react';
7 |
8 | export default function Seed() {
9 | const { input, setInput } = useInput();
10 |
11 | return (
12 | Seed
15 | }
16 | >
17 |
18 |
setInput({ seed: e.target.value })}
21 | // onKeyDown={handleKeyDown}
22 | value={input.seed}
23 | />
24 |
25 | {
28 | const value = Math.abs(
29 | (Math.random() * 2 ** 32) | 0
30 | ) as unknown as string;
31 | setInput({ seed: value });
32 | }}
33 | >
34 |
35 |
36 | {
41 | setInput({ seed: '' });
42 | }}
43 | >
44 |
45 |
46 |
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/app/_components/AdvancedOptions/Steps.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useInput } from '@/app/_providers/PromptInputProvider'
3 | import NumberInput from '../NumberInput'
4 | import OptionLabel from './OptionLabel'
5 |
6 | export default function Steps() {
7 | const { input, setInput } = useInput()
8 |
9 | return (
10 | Steps
14 | }
15 | >
16 |
17 | {
21 | if (isNaN(input.steps)) {
22 | setInput({ steps: 24 })
23 | } else {
24 | setInput({
25 | steps: parseFloat(Number(input.steps).toFixed(0))
26 | })
27 | }
28 | }}
29 | onChange={(num) => {
30 | setInput({ steps: Number(num) as unknown as number })
31 | }}
32 | onMinusClick={() => {
33 | if (Number(input.steps) - 1 < 1) {
34 | return
35 | }
36 |
37 | setInput({ steps: Number(input.steps) - 1 })
38 | }}
39 | onPlusClick={() => {
40 | if (Number(input.steps) + 1 > 50) {
41 | return
42 | }
43 |
44 | setInput({ steps: Number(input.steps) + 1 })
45 | }}
46 | value={input.steps}
47 | />
48 |
49 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/app/_components/AdvancedOptions/StylePresetSelect/index.tsx:
--------------------------------------------------------------------------------
1 | import StylePresetSelectComponent from './stylePresetSelectComponent';
2 | import { getPresetData } from '@/app/_api/presets';
3 |
4 | export default async function StylePresetSelect() {
5 | const data = await getPresetData();
6 |
7 | return (
8 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/app/_components/AdvancedOptions/UploadImage/uploadImage.module.css:
--------------------------------------------------------------------------------
1 | .Dropzone {
2 | align-items: center;
3 | border-color: white;
4 | border-radius: 4px;
5 | border-style: dashed;
6 | border-width: 2px;
7 | color: white;
8 | cursor: pointer;
9 | display: flex;
10 | flex-direction: column;
11 | flex-shrink: 0;
12 | font-size: 16px;
13 | height: 80px;
14 | justify-content: center;
15 | outline: none;
16 | padding: 16px;
17 | text-align: center;
18 | transition: border 0.24s ease-in-out;
19 | width: 100%;
20 | }
--------------------------------------------------------------------------------
/app/_components/AdvancedOptions/index.tsx:
--------------------------------------------------------------------------------
1 | import Section from '../Section'
2 | import AddEmbedding from './AddEmbedding'
3 | import AddLora from './LoRAs/AddLora'
4 | import AdditionalOptions from './AdditionalOptions'
5 | import ClipSkip from './ClipSkip'
6 | import FaceFixers from './FaceFixers'
7 | import Guidance from './Guidance'
8 | import HordeSettings from './HordeSettings'
9 | import ImageCount from './ImageCount'
10 | import ImageOrientation from './ImageOrientation'
11 | import ImageProcessing from './ImageProcessing'
12 | import ModelSelect from './ModelSelect'
13 | import SamplerSelect from './SamplerSelect'
14 | import Seed from './Seed'
15 | import Steps from './Steps'
16 | import Upscalers from './Upscalers'
17 | import StylePresetSelect from './StylePresetSelect'
18 | import AddWorkflow from './AddWorkflow'
19 | import HiresFix from './HiresFix'
20 | import UploadImage from './UploadImage'
21 |
22 | export default function AdvancedOptions() {
23 | return (
24 |
25 |
26 |
27 |
30 |
34 |
38 |
43 |
44 |
45 |
48 |
49 |
52 |
55 |
58 |
59 |
60 |
61 | )
62 | }
63 |
--------------------------------------------------------------------------------
/app/_components/AppInit/index.tsx:
--------------------------------------------------------------------------------
1 | import { getModelsData } from '@/app/_api/models'
2 | import AppInitComponent from './AppInitComponent'
3 | import { Suspense } from 'react'
4 |
5 | export default async function AppInit() {
6 | const { modelsAvailable, modelDetails } = await getModelsData()
7 | return (
8 |
9 |
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/app/_components/Button/button.module.css:
--------------------------------------------------------------------------------
1 | .Button {
2 | align-items: center !important;
3 | background-color: rgb(106, 183, 198);
4 | border: 1px solid rgb(106, 183, 198);
5 | border-radius: 4px;
6 | color: white;
7 | display: flex;
8 | flex-direction: row;
9 | gap: 2px;
10 | height: 42px;
11 | padding: 2px 8px;
12 | }
13 |
14 | .ButtonText {
15 | align-items: center;
16 | flex-direction: row;
17 | justify-content: center;
18 | display: flex;
19 | font-size: 12px;
20 | line-height: 20px;
21 | width: 100%;
22 | }
23 |
24 | .Button:active {
25 | transform: scale(0.96);
26 | }
27 |
28 | .Button:hover {
29 | background-color: #8ac5d1;
30 | }
31 |
32 | /* Additional types and classes */
33 |
34 | .danger {
35 | background-color: #f25657;
36 | border-color: #f25657;
37 | color: white;
38 | font-weight: 700;
39 | }
40 |
41 | .danger:hover {
42 | background-color: #f9595a;
43 | }
44 |
45 | .success {
46 | background-color: #3F876B;
47 | border-color: #3F876B;
48 | font-weight: 700;
49 | color: white;
50 | }
51 |
52 | .success:hover {
53 | background-color: #3F876B;
54 | }
55 |
56 | .warning {
57 | background-color: orange;
58 | border-color: orange;
59 | font-weight: 700;
60 | color: white;
61 | }
62 |
63 | .warning:hover {
64 | background-color: #FFC107;
65 | }
66 |
67 | .disabled {
68 | cursor: default;
69 | background-color: #a9b1b9;
70 | border-color: #a9b1b9;
71 | }
72 |
73 | .disabled:hover {
74 | cursor: default;
75 | background-color: #a9b1b9;
76 | }
77 |
78 | .disabled:active {
79 | transform: unset;
80 | }
81 |
82 | /* Generic modifiers that can apply to whole button */
83 | .outline {
84 | background-color: transparent;
85 | border: 1px solid rgb(106, 183, 198);
86 | color: rgb(106, 183, 198);
87 | }
88 |
89 | .outline:hover {
90 | background-color: #24454b;
91 | /* Example for different hover background color */
92 | /* color: #ffffff; */
93 | /* Example for hover text color */
94 | }
--------------------------------------------------------------------------------
/app/_components/Button/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import clsx from 'clsx';
4 | import { CSSProperties, ReactNode, ElementType, forwardRef } from 'react';
5 | import styles from './button.module.css';
6 |
7 | // Extend this as needed
8 | type ButtonTheme = 'default' | 'danger' | 'warning' | 'success';
9 |
10 | interface ButtonProps {
11 | as?: T;
12 | className?: string;
13 | children: ReactNode;
14 | disabled?: boolean;
15 | onClick?: () => void;
16 | outline?: boolean;
17 | style?: CSSProperties;
18 | title?: string;
19 | type?: 'button' | 'submit';
20 | theme?: ButtonTheme;
21 | }
22 |
23 | type PolymorphicRef =
24 | React.ComponentPropsWithRef['ref'];
25 |
26 | const Button = forwardRef(
27 | (
28 | {
29 | as,
30 | className,
31 | children,
32 | disabled = false,
33 | onClick = () => {},
34 | outline = false,
35 | style,
36 | title,
37 | theme = 'default',
38 | type = 'button',
39 | ...rest
40 | }: ButtonProps,
41 | ref: PolymorphicRef
42 | ) => {
43 | const Component = as || 'button';
44 |
45 | return (
46 | {
56 | if (disabled) return;
57 | onClick();
58 | }}
59 | style={{ ...style }}
60 | title={title}
61 | type={Component === 'button' ? type : undefined}
62 | {...rest}
63 | >
64 | {children}
65 |
66 | );
67 | }
68 | );
69 |
70 | Button.displayName = 'Button';
71 | export default Button;
72 |
--------------------------------------------------------------------------------
/app/_components/Carousel/CarouselControls.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | import { useEffect } from 'react';
3 | import { PrevButton, NextButton } from './CarouselArrowButtons';
4 | import styles from './carousel.module.css';
5 |
6 | type CarouselControlsProps = {
7 | selectedIndex: number;
8 | scrollSnaps: number[];
9 | prevBtnDisabled: boolean;
10 | nextBtnDisabled: boolean;
11 | onPrevButtonClick: () => void;
12 | onNextButtonClick: () => void;
13 | };
14 |
15 | const CarouselControls: React.FC = ({
16 | selectedIndex,
17 | scrollSnaps,
18 | prevBtnDisabled,
19 | nextBtnDisabled,
20 | onPrevButtonClick,
21 | onNextButtonClick
22 | }) => {
23 | useEffect(() => {
24 | const handleKeyDown = (event: KeyboardEvent) => {
25 | if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
26 | event.preventDefault();
27 | if (event.key === 'ArrowLeft') {
28 | onPrevButtonClick();
29 | } else if (event.key === 'ArrowRight') {
30 | onNextButtonClick();
31 | }
32 | }
33 | };
34 |
35 | window.addEventListener('keydown', handleKeyDown);
36 |
37 | return () => {
38 | window.removeEventListener('keydown', handleKeyDown);
39 | };
40 | }, [onNextButtonClick, onPrevButtonClick]);
41 |
42 | return (
43 |
44 |
48 |
49 |
50 |
51 | {selectedIndex + 1} / {scrollSnaps.length}
52 |
53 |
54 |
55 | );
56 | };
57 |
58 | export default CarouselControls;
59 |
--------------------------------------------------------------------------------
/app/_components/Carousel/CarouselImage.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | import { useEffect, useState } from 'react';
3 | import Image from '../Image';
4 | import { ImageBlobBuffer } from '@/app/_data-models/ImageFile_Dexie';
5 |
6 | interface CarouselImageProps {
7 | imageBlobBuffer: ImageBlobBuffer;
8 | maxHeight?: number;
9 | maxWidth?: number;
10 | }
11 |
12 | const CarouselImage: React.FC = ({ imageBlobBuffer }) => {
13 | const [windowHeight, setWindowHeight] = useState(window.innerHeight);
14 |
15 | useEffect(() => {
16 | const handleResize = () => {
17 | setWindowHeight(window.innerHeight);
18 | };
19 |
20 | window.addEventListener('resize', handleResize);
21 | return () => {
22 | window.removeEventListener('resize', handleResize);
23 | };
24 | }, []);
25 |
26 | if (!imageBlobBuffer) return null;
27 |
28 | return (
29 |
40 | );
41 | };
42 |
43 | export default CarouselImage;
44 |
--------------------------------------------------------------------------------
/app/_components/ContentWrapper.tsx:
--------------------------------------------------------------------------------
1 | export default function ContentWrapper({
2 | children
3 | }: {
4 | children: React.ReactNode;
5 | }) {
6 | return (
7 |
11 | {children}
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/app/_components/DropdownMenu/dropdownMenu.module.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/app/_components/DropdownMenu/dropdownMenu.module.css
--------------------------------------------------------------------------------
/app/_components/DropdownMenu/index.tsx:
--------------------------------------------------------------------------------
1 | import { Menu, MenuButton, MenuProps } from '@szhsin/react-menu';
2 | import '@szhsin/react-menu/dist/index.css';
3 | import '@szhsin/react-menu/dist/transitions/slide.css';
4 |
5 | type MenuButtonRenderProp = (
6 | modifiers: Readonly<{ open: boolean }>
7 | ) => React.ReactElement;
8 |
9 | interface DropdownMenuProps extends Omit {
10 | children: React.ReactNode;
11 | menuButton: React.ReactNode | MenuButtonRenderProp;
12 | }
13 |
14 | export default function DropdownMenu(props: DropdownMenuProps) {
15 | const { children, menuButton, ...rest } = props;
16 |
17 | const renderMenuButton = (modifiers: Readonly<{ open: boolean }>) => {
18 | if (typeof menuButton === 'function') {
19 | return menuButton(modifiers);
20 | }
21 | return {menuButton} ;
22 | };
23 |
24 | return (
25 |
26 | {children}
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/app/_components/Footer/AnimatedEmoji.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 |
3 | const emojis = [
4 | '🎨',
5 | '😀',
6 | '🖌️',
7 | '🖍️',
8 | '☀️',
9 | '🍻',
10 | '❤️',
11 | '🎉',
12 | '🦄',
13 | '🤖',
14 | '💻',
15 | '😬',
16 | '🤪',
17 | '✨',
18 | '🚀',
19 | '🥴'
20 | ];
21 |
22 | export default function AnimatedEmoji() {
23 | const [currentEmoji, setCurrentEmoji] = useState(emojis[0]);
24 |
25 | useEffect(() => {
26 | const interval = setInterval(() => {
27 | setCurrentEmoji(emojis[Math.floor(Math.random() * emojis.length)]);
28 | }, 2500);
29 |
30 | return () => clearInterval(interval);
31 | }, []);
32 |
33 | return {currentEmoji} ;
34 | }
35 |
--------------------------------------------------------------------------------
/app/_components/Footer/buildId.tsx:
--------------------------------------------------------------------------------
1 | import { AppStore } from '@/app/_stores/AppStore';
2 | import { useStore } from 'statery';
3 |
4 | export default function BuildId() {
5 | const { buildId } = useStore(AppStore);
6 |
7 | if (!buildId) return null;
8 |
9 | return (
10 |
11 | build v.{buildId}
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/app/_components/Footer/footer.module.css:
--------------------------------------------------------------------------------
1 | .Footer {
2 | background-color: var(--footer-background);
3 | color: white;
4 | display: flex;
5 | flex-direction: column;
6 | padding: 16px 8px 16px 16px;
7 | padding-bottom: 72px;
8 | width: 100%;
9 | }
10 |
11 | .SectionsWrapper {
12 | column-gap: 32px;
13 | display: flex;
14 | flex-direction: row;
15 | flex-wrap: wrap;
16 | justify-content: space-between;
17 | row-gap: 32px;
18 | }
19 |
20 | .Section {
21 | display: flex;
22 | flex-direction: column;
23 | font-size: 14px;
24 | gap: 4px;
25 | margin-right: 8px;
26 | overflow-wrap: break-word;
27 | width: 150px;
28 | }
29 |
30 | .SectionTitle {
31 | display: flex;
32 | flex-direction: row;
33 | gap: 4px;
34 | font-size: 16px;
35 | font-weight: 700;
36 | }
37 |
38 | .AboutWrapper {
39 | margin-top: 32px;
40 | /* padding-bottom: 16px; */
41 | text-align: center;
42 | }
43 |
44 | .LinkWrapper:hover {
45 | color: var(--link-active);
46 | }
47 |
48 | @media (min-width: 640px) {
49 | .AboutWrapper {
50 | padding-bottom: 0;
51 | }
52 |
53 | .Footer {
54 | justify-content: center;
55 | padding: 16px;
56 | padding-bottom: 16px;
57 | }
58 |
59 | .SectionsWrapper {
60 | flex-direction: row;
61 | justify-content: unset;
62 | max-width: 1400px;
63 | margin: 0 auto;
64 | }
65 | }
66 |
67 | @media (min-width: 800px) {
68 | .Footer {
69 | padding: 32px;
70 | }
71 | }
72 |
73 | @media (prefers-color-scheme: dark) {
74 | .Footer {
75 | background-color: var(--footer-background);
76 | }
77 | }
--------------------------------------------------------------------------------
/app/_components/Fullscreen/fullscreen.module.css:
--------------------------------------------------------------------------------
1 | .fullscreen-modal-overlay {
2 | position: fixed;
3 | top: 0;
4 | left: 0;
5 | width: 100%;
6 | height: 100%;
7 | background: rgba(0, 0, 0, 1);
8 | display: flex;
9 | justify-content: center;
10 | align-items: center;
11 | z-index: 1000;
12 | }
13 |
14 | .fullscreen-modal-content {
15 | position: fixed;
16 | top: 0;
17 | left: 0;
18 | width: 100%;
19 | height: 100%;
20 | background: transparent;
21 | display: flex;
22 | flex-direction: row;
23 | justify-content: center;
24 | align-items: center;
25 | padding: 16px;
26 | overflow: auto;
27 | }
28 |
29 | .fullscreen-modal-close {
30 | position: absolute;
31 | top: 0;
32 | right: 10px;
33 | background: transparent;
34 | border: none;
35 | font-size: 28px;
36 | cursor: pointer;
37 | color: white;
38 | }
--------------------------------------------------------------------------------
/app/_components/Fullscreen/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import styles from './fullscreen.module.css';
4 |
5 | interface FullScreenModalProps {
6 | onClick?: () => void;
7 | onClose: () => void;
8 | children: React.ReactNode;
9 | }
10 |
11 | const FullScreenModal: React.FC = ({
12 | onClose = () => {},
13 | onClick = () => {},
14 | children
15 | }) => {
16 | useEffect(() => {
17 | const handleEscape = (event: KeyboardEvent) => {
18 | if (event.key === 'Escape') {
19 | event.preventDefault();
20 | event.stopPropagation();
21 | onClose();
22 | }
23 | };
24 |
25 | document.addEventListener('keydown', handleEscape, { capture: true });
26 |
27 | return () => {
28 | document.removeEventListener('keydown', handleEscape, { capture: true });
29 | };
30 | }, [onClose]);
31 |
32 | return ReactDOM.createPortal(
33 |
34 |
{
37 | e.stopPropagation();
38 | onClick();
39 | onClose();
40 | }}
41 | >
42 |
43 | ×
44 |
45 | {children}
46 |
47 |
,
48 | document.body
49 | );
50 | };
51 |
52 | export default FullScreenModal;
53 |
--------------------------------------------------------------------------------
/app/_components/GalleryImageCardOverlay/galleryImageCardOverlay.module.css:
--------------------------------------------------------------------------------
1 | .ImageCardOverlay {
2 | display: flex;
3 | align-items: center;
4 | justify-content: center;
5 | position: absolute;
6 | }
7 |
8 | .ImageCount {
9 | align-items: center;
10 | background-color: black;
11 | bottom: 0;
12 | color: white;
13 | display: flex;
14 | flex-direction: row;
15 | font-size: 12px;
16 | gap: 4px;
17 | height: 24px;
18 | padding: 4px;
19 | left: 0;
20 | position: absolute;
21 | opacity: .75;
22 | width: 40px;
23 | }
--------------------------------------------------------------------------------
/app/_components/GalleryImageCardOverlay/index.tsx:
--------------------------------------------------------------------------------
1 | import { IconLibraryPhoto } from '@tabler/icons-react'
2 | import styles from './galleryImageCardOverlay.module.css'
3 | import { CSSProperties } from 'react'
4 |
5 | export default function GalleryImageCardOverlay({
6 | imageCount,
7 | style
8 | }: {
9 | imageCount: number
10 | style?: CSSProperties
11 | }) {
12 | return (
13 |
14 | {imageCount > 1 && (
15 |
16 |
17 | {imageCount}
18 |
19 | )}
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/app/_components/HeaderNav/HeaderNav_ArtBotOffline.tsx:
--------------------------------------------------------------------------------
1 | import NiceModal from '@ebay/nice-modal-react';
2 | import HeaderNav_IconWrapper from './_HeaderNav_IconWrapper';
3 | import { IconWifiOff } from '@tabler/icons-react';
4 | import { useStore } from 'statery';
5 | import { AppStore } from '@/app/_stores/AppStore';
6 |
7 | export default function HeaderNavArtBotOffline() {
8 | const { online } = useStore(AppStore);
9 |
10 | if (online) return null;
11 |
12 | return (
13 | {
15 | NiceModal.show('modal', {
16 | children: (
17 |
18 |
Connection error
19 | ArtBot is currently having trouble conecting to its server. You
20 | may encounter unexpected errors.
21 |
22 | ),
23 | modalClassName: 'max-w-[640px]'
24 | });
25 | }}
26 | title="ArtBot server is currently offline."
27 | >
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/app/_components/HeaderNav/HeaderNav_ForceWorker.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { UserStore } from '@/app/_stores/UserStore';
4 | import ForceWorkerModal from '@/app/(content)/create/_component/ForceWorkerModal';
5 | import NiceModal from '@ebay/nice-modal-react';
6 | import { IconAlertTriangleFilled } from '@tabler/icons-react';
7 | import { useStore } from 'statery';
8 | import HeaderNav_IconWrapper from './_HeaderNav_IconWrapper';
9 |
10 | export default function HeaderNavForceWorker() {
11 | const { forceSelectedWorker } = useStore(UserStore);
12 | if (!forceSelectedWorker) return null;
13 |
14 | return (
15 | {
17 | NiceModal.show('modal', {
18 | children: ,
19 | modalClassName: 'max-w-[640px]'
20 | });
21 | }}
22 | title="Requests locked to specific worker(s)"
23 | >
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/app/_components/HeaderNav/HeaderNav_HordeOffline.tsx:
--------------------------------------------------------------------------------
1 | import NiceModal from '@ebay/nice-modal-react';
2 | import { IconAlertTriangle } from '@tabler/icons-react';
3 | import { useStore } from 'statery';
4 | import { AppStore } from '@/app/_stores/AppStore';
5 | import HeaderNav_IconWrapper from './_HeaderNav_IconWrapper';
6 |
7 | const HordeOfflineModal = () => {
8 | return (
9 |
13 |
14 |
15 |
16 |
17 | AI Horde offline
18 |
19 |
20 |
21 | ArtBot has encountered an issue while attempting to contact the AI
22 | Horde API. It may potentially be down. Network requests could be
23 | affected.
24 |
25 |
Please check again soon.
26 |
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | export default function HeaderNav_HordeOffline() {
34 | const { hordeOnline } = useStore(AppStore);
35 |
36 | if (hordeOnline) {
37 | return null;
38 | }
39 |
40 | return (
41 | {
43 | NiceModal.show('modal', {
44 | children:
45 | });
46 | }}
47 | title="AI Horde is offline"
48 | >
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/app/_components/HeaderNav/HeaderNav_HordePerformance.tsx:
--------------------------------------------------------------------------------
1 | import NiceModal from '@ebay/nice-modal-react';
2 | import { IconDeviceDesktopAnalytics } from '@tabler/icons-react';
3 | import HordePerformanceModal from '../Modals/Modal_HordePerformance';
4 | import HeaderNav_IconWrapper from './_HeaderNav_IconWrapper';
5 |
6 | export default function HeaderNavHordePerformance() {
7 | return (
8 | {
10 | NiceModal.show('hordePerfModal', {
11 | children:
12 | });
13 | }}
14 | title="AI Horde performance"
15 | >
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/app/_components/HeaderNav/HeaderNav_PendingJobs.tsx:
--------------------------------------------------------------------------------
1 | import { useStore } from 'statery'
2 | import { IconPhoto, IconPhotoCheck } from '@tabler/icons-react'
3 | import Link from 'next/link'
4 | import {
5 | PendingImagesStore,
6 | viewedPendingPage
7 | } from '../../_stores/PendingImagesStore'
8 |
9 | export default function HeaderNavPendingJobs() {
10 | const { completedJobsNotViewed, pendingImages } = useStore(PendingImagesStore)
11 |
12 | if (pendingImages.length === 0 && completedJobsNotViewed === 0) {
13 | return null
14 | }
15 |
16 | return (
17 |
18 |
{
21 | viewedPendingPage()
22 | }}
23 | >
24 |
25 | {pendingImages.length === 0 ? (
26 |
27 | ) : (
28 |
29 | )}
30 | {completedJobsNotViewed > 0 && (
31 |
50 | {completedJobsNotViewed}
51 |
52 | )}
53 |
54 |
55 |
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/app/_components/HeaderNav/HeaderNav_UserKudos.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { IconCoins } from '@tabler/icons-react';
4 | import { useEffect, useState } from 'react';
5 | import { ThreeDots } from 'react-loader-spinner';
6 | import { useStore } from 'statery';
7 |
8 | // import UserKudosModal from '@/app/_components/modals/UserKudosModal'
9 | import { AppSettings } from '@/app/_data-models/AppSettings';
10 | import { formatKudos } from '@/app/_utils/numberUtils';
11 | import NiceModal from '@ebay/nice-modal-react';
12 |
13 | import { UserStore } from '../../_stores/UserStore';
14 | import UserKudosModal from '../Modal_UserKudos';
15 | import { AppConstants } from '../../_data-models/AppConstants';
16 |
17 | export default function UserKudos() {
18 | const { sharedKey, userDetails } = useStore(UserStore);
19 | const { kudos } = userDetails;
20 |
21 | // Prevent hydration warnings
22 | const [clientApiKey, setClientApiKey] = useState(null);
23 | const [isClient, setIsClient] = useState(false);
24 |
25 | // Prevent hydration warnings
26 | useEffect(() => {
27 | const apikey = AppSettings.apikey();
28 |
29 | if (!apikey || !apikey.trim() || apikey === AppConstants.AI_HORDE_ANON_KEY)
30 | return;
31 | setClientApiKey(apikey);
32 | setIsClient(true);
33 | }, [userDetails]);
34 |
35 | if (
36 | !isClient ||
37 | (!clientApiKey && !userDetails) ||
38 | (!clientApiKey && !userDetails.username) ||
39 | (!userDetails.username && !sharedKey)
40 | ) {
41 | return null;
42 | }
43 |
44 | return (
45 | {
48 | NiceModal.show('modal', {
49 | children:
50 | });
51 | }}
52 | title="User kudos"
53 | >
54 |
55 | {clientApiKey && !userDetails.username ? (
56 |
57 |
67 |
68 | ) : (
69 | {formatKudos(kudos)}
70 | )}
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/app/_components/HeaderNav/_HeaderNav_IconWrapper.tsx:
--------------------------------------------------------------------------------
1 | export default function HeaderNav_IconWrapper({
2 | children,
3 | onClick = () => {},
4 | title
5 | }: {
6 | children: React.ReactNode;
7 | onClick: () => void;
8 | title?: string;
9 | }) {
10 | return (
11 |
16 | <>{children}>
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/app/_components/Image.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | import { useState, useEffect } from 'react';
3 | import { ImageBlobBuffer } from '../_data-models/ImageFile_Dexie';
4 | import { bufferToBlob } from '../_utils/imageUtils';
5 |
6 | const defaultImage =
7 | 'data:image/gif;base64,R0lGODdhAQABAJEAAAAAAB8fH////wAAACH5BAkAAAMALAAAAAABAAEAAAICTAEAOw==';
8 |
9 | const Image = ({
10 | alt = '',
11 | className,
12 | imageBlobBuffer,
13 | style
14 | }: {
15 | alt?: string;
16 | className?: string;
17 | imageBlobBuffer?: ImageBlobBuffer;
18 | style?: React.CSSProperties;
19 | }) => {
20 | const [imageUrl, setImageUrl] = useState(defaultImage);
21 |
22 | useEffect(() => {
23 | if (!imageBlobBuffer) {
24 | setImageUrl(defaultImage);
25 | return;
26 | }
27 | const blob = bufferToBlob(imageBlobBuffer);
28 | const url = URL.createObjectURL(blob as Blob);
29 | setImageUrl(url);
30 |
31 | return () => {
32 | URL.revokeObjectURL(url);
33 | };
34 | }, [imageBlobBuffer]);
35 |
36 | return ;
37 | };
38 |
39 | export default Image;
40 |
--------------------------------------------------------------------------------
/app/_components/ImageView/ImageViewImage.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | import React, { useEffect, useState } from 'react';
3 | import { useImageView } from './ImageViewProvider';
4 | import CarouselImage from '../Carousel/CarouselImage';
5 | import Carousel from '../Carousel';
6 | import { ImageBlobBuffer } from '@/app/_data-models/ImageFile_Dexie';
7 |
8 | // Prevents re-rendering of the same image multiple times as parent is updated
9 | const ImageViewImage = React.memo(() => {
10 | const [windowHeight, setWindowHeight] = useState(window.innerHeight);
11 | const { imageBlobBuffer, imageData, setCurrentImageId } = useImageView();
12 | const { imageFiles } = imageData;
13 |
14 | useEffect(() => {
15 | const handleResize = () => {
16 | setWindowHeight(window.innerHeight);
17 | };
18 |
19 | window.addEventListener('resize', handleResize);
20 | return () => {
21 | window.removeEventListener('resize', handleResize);
22 | };
23 | }, []);
24 |
25 | if (!imageFiles) return null;
26 | if (!imageBlobBuffer) return null;
27 |
28 | // Constrain max width of image so that multiple images don't show in carousel.
29 | let imageWidth =
30 | ((windowHeight - 72) * imageData?.imageRequest?.width) /
31 | imageData?.imageRequest?.height;
32 |
33 | // Handle scenario where image width is smaller than calculated value
34 | if (imageData?.imageRequest?.width < imageWidth) {
35 | imageWidth = imageData?.imageRequest?.width;
36 | }
37 |
38 | return (
39 |
40 | {
43 | setCurrentImageId(imageFiles[num].image_id);
44 | }}
45 | options={{ loop: true }}
46 | >
47 | {imageFiles.map((image) => (
48 |
53 | ))}
54 |
55 |
56 | );
57 | });
58 |
59 | ImageViewImage.displayName = 'ImageViewImage';
60 | export default ImageViewImage;
61 |
--------------------------------------------------------------------------------
/app/_components/ImageView/ImageViewSourceImage.tsx:
--------------------------------------------------------------------------------
1 | import { ImageFileInterface } from '@/app/_data-models/ImageFile_Dexie'
2 | import { getSourceImagesForArtbotJobFromDexie } from '@/app/_db/ImageFiles'
3 | import { JobDetails } from '@/app/_hooks/useImageDetails'
4 | import { useEffect, useState } from 'react'
5 | import ImageThumbnail from '../ImageThumbnail'
6 | import { IconPhotoUp } from '@tabler/icons-react'
7 |
8 | export default function ImageViewSourceImage({
9 | imageDetails
10 | }: {
11 | imageDetails: JobDetails
12 | }) {
13 | const [srcImages, setSrcImages] = useState([])
14 |
15 | useEffect(() => {
16 | async function fetchSrcImages() {
17 | if (!imageDetails || !imageDetails?.jobDetails?.artbot_id) return
18 | const images = await getSourceImagesForArtbotJobFromDexie(
19 | imageDetails.jobDetails.artbot_id
20 | )
21 | setSrcImages(images)
22 | }
23 |
24 | fetchSrcImages()
25 | }, [imageDetails])
26 |
27 | if (srcImages.length === 0) return null
28 |
29 | return (
30 |
31 |
32 |
33 | Source image
34 |
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/app/_components/ImageView/imageView.module.css:
--------------------------------------------------------------------------------
1 | .ImageViewer {
2 | display: flex;
3 | flex-direction: column;
4 | width: 100%;
5 | justify-content: center;
6 | }
7 |
8 | .ImageViewImage {
9 | max-width: unset;
10 | object-fit: contain;
11 | width: 'auto';
12 | }
13 |
14 | .ImageInfoContainer {
15 | align-items: flex-start;
16 | display: flex;
17 | flex-direction: column;
18 | gap: 12px;
19 | padding-right: 0.25rem;
20 | margin-top: 12px;
21 | width: 100%;
22 | }
23 |
24 | @media (min-width: 950px) {
25 | .ImageViewer {
26 | flex-direction: row;
27 | align-items: flex-start;
28 | justify-content: flex-start;
29 | gap: 24px;
30 | }
31 |
32 | .ImageViewImage {
33 | max-width: 683px;
34 | }
35 |
36 | .ImageInfoContainer {
37 | margin-top: 0;
38 | width: 388px;
39 | }
40 | }
41 |
42 | @media (min-width: 1532px) {
43 | .ImageViewImage {
44 | max-width: 1024px;
45 | }
46 | }
--------------------------------------------------------------------------------
/app/_components/ImageView/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import ImageViewImage from './ImageViewImage'
4 | import styles from './imageView.module.css'
5 | import { ImageViewProvider } from './ImageViewProvider'
6 | import ImageViewInfoContainer from './ImageViewInfoContainer'
7 |
8 | export default function ImageView({
9 | artbot_id,
10 | image_id,
11 | onDelete = () => {},
12 | showPendingPanel = false,
13 | singleImage = false
14 | }: {
15 | artbot_id: string
16 | image_id?: string
17 | onDelete?: () => void
18 | showPendingPanel?: boolean
19 | singleImage?: boolean
20 | }) {
21 | return (
22 |
27 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/app/_components/Input.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | export default function Input({
4 | label,
5 | onChange = () => {},
6 | onKeyDown = () => {},
7 | placeholder,
8 | type,
9 | value
10 | }: {
11 | label?: string
12 | onChange?: (e: React.ChangeEvent) => void
13 | onKeyDown?: (e: React.KeyboardEvent) => void
14 | placeholder?: string
15 | type?: string
16 | value?: string
17 | }) {
18 | return (
19 |
20 | {label && (
21 |
22 | {label}
23 |
24 | )}
25 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/app/_components/Linker/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Link from 'next/link';
4 | import { CSSProperties } from 'react';
5 | import clsx from 'clsx';
6 | import styles from './linker.module.css';
7 |
8 | interface LinkerProps {
9 | children?: React.ReactNode;
10 | disableLinkClick?: boolean;
11 | inline?: boolean;
12 | onClick?: () => void;
13 | href: string;
14 | rel?: string;
15 | target?: string;
16 | inverted?: boolean;
17 | }
18 |
19 | const Linker = (props: LinkerProps) => {
20 | const {
21 | children,
22 | disableLinkClick = false,
23 | inline,
24 | onClick = () => {},
25 | inverted = false,
26 | ...rest
27 | } = props;
28 |
29 | const handleClick = (e: React.MouseEvent) => {
30 | // Handle scenario where we want to have a link available for middle click / open new tab,
31 | // but we want the normal left click event to do something else.
32 | if (disableLinkClick) {
33 | e.preventDefault();
34 | e.stopPropagation();
35 | }
36 |
37 | onClick();
38 | };
39 |
40 | const style: CSSProperties = {};
41 |
42 | if (inline) {
43 | style.display = 'inline-block';
44 | }
45 |
46 | let target = '';
47 |
48 | if (
49 | props.href &&
50 | props.href.indexOf('https://') === 0 &&
51 | props.href.indexOf('https://tinybots.net') !== 0
52 | ) {
53 | target = '_blank';
54 | }
55 |
56 | return (
57 |
67 | {children}
68 |
69 | );
70 | };
71 |
72 | export default Linker;
73 |
--------------------------------------------------------------------------------
/app/_components/Linker/linker.module.css:
--------------------------------------------------------------------------------
1 | .Linker {
2 | color: var(--link-text);
3 | cursor: pointer;
4 | display: inline-block;
5 | font-weight: 600;
6 | }
7 |
8 | .Linker:hover {
9 | color: var(--link-active);
10 | }
11 |
12 | .Linker.inverted {
13 | color: var(--link-active);
14 | }
15 |
16 | .Linker.inverted:hover {
17 | color: var(--link-text);
18 | }
--------------------------------------------------------------------------------
/app/_components/Masonry/MasonryItem.tsx:
--------------------------------------------------------------------------------
1 | import styles from './masonry.module.css'
2 |
3 | export default function MasonryItem({
4 | children
5 | }: {
6 | children: React.ReactNode
7 | }) {
8 | return {children}
9 | }
10 |
--------------------------------------------------------------------------------
/app/_components/Masonry/MasonryLayout.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useContainerWidth } from '@/app/_hooks/useContainerWidth'
4 | import { ReactNode, ReactElement, Children, useRef, RefObject } from 'react'
5 |
6 | interface MasonryLayoutProps {
7 | children: ReactNode
8 | gap?: number
9 | containerRef?: RefObject
10 | }
11 |
12 | const MasonryLayout = ({
13 | children,
14 | gap = 20,
15 | containerRef
16 | }: MasonryLayoutProps) => {
17 | const defaultRef = useRef(null)
18 | const width = useContainerWidth(containerRef || defaultRef)
19 |
20 | if (!children || !width) return null
21 |
22 | let columns = 2
23 | if (width > 1900) {
24 | columns = 7
25 | } else if (width > 1700) {
26 | columns = 6
27 | } else if (width > 1500) {
28 | columns = 5
29 | } else if (width > 1200) {
30 | columns = 4
31 | } else if (width > 1000) {
32 | columns = 3
33 | } else if (width > 800) {
34 | columns = 2
35 | }
36 |
37 | const childrenArray = Children.toArray(children)
38 | if (childrenArray.length < columns) {
39 | columns = childrenArray.length
40 | }
41 |
42 | const columnWrapper: { [key: string]: ReactElement[] } = {}
43 | const result: ReactElement[] = []
44 |
45 | // create columns
46 | for (let i = 0; i < columns; i++) {
47 | columnWrapper[`column${i}`] = []
48 | }
49 |
50 | // divide children into columns
51 | for (let i = 0; i < childrenArray.length; i++) {
52 | const columnIndex = i % columns
53 | columnWrapper[`column${columnIndex}`].push(
54 |
58 | {childrenArray[i]}
59 |
60 | )
61 | }
62 |
63 | // wrap children in each column with a div
64 | for (let i = 0; i < columns; i++) {
65 | result.push(
66 | 0 ? gap : 0}px`,
69 | flex: 1
70 | }}
71 | key={`col_${i}`}
72 | className="is_col"
73 | >
74 | {columnWrapper[`column${i}`]}
75 |
76 | )
77 | }
78 |
79 | return (
80 |
81 | {result}
82 |
83 | )
84 | }
85 |
86 | export default MasonryLayout
87 |
--------------------------------------------------------------------------------
/app/_components/Masonry/index.tsx:
--------------------------------------------------------------------------------
1 | export { default as MasonryLayout } from './MasonryLayout'
2 | export { default as MasonryItem } from './MasonryItem'
3 |
--------------------------------------------------------------------------------
/app/_components/Masonry/masonry.module.css:
--------------------------------------------------------------------------------
1 | .masonry-item {
2 | cursor: pointer;
3 | margin-bottom: 1rem;
4 | break-inside: avoid;
5 | display: inline-block;
6 | position: relative;
7 | width: 100%;
8 | }
9 |
10 | .masonry-item img {
11 | width: 100%;
12 | height: auto;
13 | display: block;
14 | }
--------------------------------------------------------------------------------
/app/_components/MobileFooter/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { usePathname } from 'next/navigation'
3 |
4 | import Link from 'next/link'
5 | import styles from './mobileFooter.module.css'
6 | import clsx from 'clsx'
7 | import {
8 | IconEdit,
9 | IconHourglass,
10 | IconPhoto,
11 | IconSettings
12 | } from '@tabler/icons-react'
13 |
14 | export default function MobileFooter() {
15 | const pathname = usePathname()
16 |
17 | const isActive = (path = '') => {
18 | return path === pathname
19 | }
20 |
21 | return (
22 |
23 |
24 |
25 |
26 | {isActive('/create') &&
}
27 |
28 |
29 |
30 |
31 |
32 | {isActive('/pending') &&
}
33 |
38 |
39 |
40 |
41 |
42 | {isActive('/images') &&
}
43 |
44 |
45 |
46 |
47 |
48 | {isActive('/settings') &&
}
49 |
54 |
55 |
56 |
57 |
58 | )
59 | }
60 |
--------------------------------------------------------------------------------
/app/_components/MobileFooter/mobileFooter.module.css:
--------------------------------------------------------------------------------
1 | .footer-wrapper {
2 | align-items: flex-start;
3 | background-color: var(--background-color);
4 | border-top: 1px solid black;
5 | position: fixed;
6 | bottom: -1px;
7 | height: calc(68px + env(safe-area-inset-bottom));
8 | left: 0;
9 | right: 0;
10 | z-index: 10;
11 | }
12 |
13 | .nav-icons-wrapper {
14 | align-items: center;
15 | display: flex;
16 | flex-direction: row;
17 | justify-content: space-around;
18 | width: 100%;
19 | }
20 |
21 | .nav-icon {
22 | align-items: center;
23 | /* border-top: 4px solid transparent; */
24 | color: black;
25 | display: flex;
26 | justify-content: center;
27 | height: 68px;
28 | width: 40px;
29 | }
30 |
31 | .NavIconActive {
32 | background-color: #14b8a6;
33 | height: 4px;
34 | left: 0;
35 | position: absolute;
36 | right: 0;
37 | top: 0;
38 | }
39 |
40 | .svg {
41 | stroke: black;
42 | }
43 |
44 | .nav-icon-active>svg {
45 | stroke: #14b8a6;
46 | }
47 |
48 | @media (min-width: 640px) {
49 | .footer-wrapper {
50 | display: none;
51 | }
52 | }
53 |
54 | @media (prefers-color-scheme: dark) {
55 | .footer-wrapper {
56 | border-top: 1px solid white;
57 | }
58 |
59 | .svg {
60 | stroke: white;
61 | }
62 | }
--------------------------------------------------------------------------------
/app/_components/Modal/modal.module.css:
--------------------------------------------------------------------------------
1 | .CustomModalContainer {
2 | width: 100vw;
3 | }
4 |
5 | .CustomModal {
6 | background-color: white;
7 | border-radius: 8px;
8 | color: black;
9 | max-width: calc(100% - 16px);
10 | width: 100%;
11 | padding: 8px !important;
12 | margin: 16px 0;
13 | }
14 |
15 | .CustomCloseButton {
16 | top: 8px;
17 | right: 8px;
18 | }
19 |
20 | @media (min-width: 640px) {
21 | .CustomModal {
22 | max-width: calc(100% - 32px);
23 | min-width: 320px;
24 | padding: 16px !important;
25 | width: auto;
26 | }
27 | }
28 |
29 | @media (prefers-color-scheme: dark) {
30 | .CustomModal {
31 | background-color: black;
32 | color: white;
33 | }
34 | }
--------------------------------------------------------------------------------
/app/_components/Modal_DeleteConfirmation.tsx:
--------------------------------------------------------------------------------
1 | import NiceModal from '@ebay/nice-modal-react'
2 | import { ReactNode } from 'react'
3 | import Button from './Button'
4 |
5 | export default function DeleteConfirmation({
6 | deleteButtonTitle = 'Delete',
7 | message,
8 | onDelete,
9 | title = 'Delete image?'
10 | }: {
11 | deleteButtonTitle?: string
12 | message?: ReactNode
13 | onDelete: () => void
14 | title?: string
15 | }) {
16 | return (
17 |
18 |
19 |
28 |
33 |
34 | {title}
35 |
36 |
37 | {message ? (
38 | message
39 | ) : (
40 | <>
41 |
42 | Are you sure you want to delete this image?
43 |
44 |
This action cannot be undone.
45 | >
46 | )}
47 |
48 |
49 | NiceModal.remove('delete')} outline>
50 | Cancel
51 |
52 | {
54 | NiceModal.remove('delete')
55 | onDelete()
56 | }}
57 | theme="danger"
58 | >
59 | {deleteButtonTitle}
60 |
61 |
62 |
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/app/_components/Modal_UserKudos.tsx:
--------------------------------------------------------------------------------
1 | import { IconCoins, IconStack } from '@tabler/icons-react'
2 | import { useStore } from 'statery'
3 | import { UserStore } from '../_stores/UserStore'
4 |
5 | export default function UserKudosModal() {
6 | const { userDetails } = useStore(UserStore)
7 | const { kudos, records } = userDetails
8 |
9 | return (
10 |
11 |
12 |
13 |
Total Available Kudos
14 |
15 |
16 |
17 |
18 | {kudos.toLocaleString()}
19 |
20 |
21 |
22 | {records && records.request && (
23 |
24 |
25 |
Images requested
26 |
27 |
28 |
29 |
30 | {records.request.image.toLocaleString()}
31 |
32 |
33 |
34 | )}
35 |
36 | Due to server caching, data may be a few minutes out of date.
37 |
38 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/app/_components/NotificationBanners/BetaWarningBanner/index.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 |
3 | export default function BetaWarningBanner() {
4 | return (
5 |
13 |
14 | IMPORTANT: This is a *beta* (probably even alpha) version of ArtBot v2
15 | that is rapidly changing. Feel free to play around with it! If you
16 | create images you like, save them to your machine. Otherwise, they will
17 | be lost forever. Please report issues and leave feedback in the{' '}
18 |
23 | ArtBot v2 feedback thread on Discord.
24 |
25 |
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/app/_components/NotificationBanners/NotificationsManager/index.tsx:
--------------------------------------------------------------------------------
1 | // import BetaWarningBanner from '../BetaWarningBanner';
2 |
3 | export default function NotificationsManager() {
4 | // let isBeta = false;
5 | // if (process.env.NEXT_PUBLIC_NO_BETA === 'true') isBeta = true;
6 |
7 | return null;
8 | // return <>{isBeta && }>;
9 | }
10 |
--------------------------------------------------------------------------------
/app/_components/NumberInput/numberInput.module.css:
--------------------------------------------------------------------------------
1 | .NumberInputWrapper {
2 | background-color: #edf2f7;
3 | border: 1px solid rgb(229 231 235);
4 | border-radius: 4px;
5 | display: flex;
6 | flex-direction: row;
7 | }
8 |
9 | .InputField {
10 | background-color: #edf2f7;
11 | border: 2px solid #edf2f7;
12 | border-radius: 4px;
13 | border-top-right-radius: 0;
14 | border-bottom-right-radius: 0;
15 | width: 100%;
16 | padding: 2px 4px;
17 | font-size: 16px;
18 | line-height: 24px;
19 | color: #4b5563;
20 | outline: 0;
21 | text-align: center;
22 | }
23 |
24 | .InputField:focus {
25 | border-color: rgb(168 85 247);
26 | }
27 |
28 | .ButtonsWrapper {
29 | align-items: center;
30 | display: flex;
31 | flex-direction: row;
32 | position: relative;
33 | width: 80px;
34 | }
35 |
36 | .Button {
37 | align-items: center;
38 | background-color: rgba(106, 183, 198, 1);
39 | justify-content: center;
40 | display: flex;
41 | height: 32px;
42 | width: 40px;
43 | }
44 |
45 | .ButtonDisabled {
46 | background-color: rgb(189, 189, 189);
47 | }
48 |
49 | .Button:active:not(:disabled):not(.disabled) {
50 | position: relative;
51 | top: 1px;
52 | }
53 |
54 | .Button:hover:not(:disabled):not(.disabled) {
55 | opacity: 0.85;
56 | }
57 |
58 | .Button:nth-child(1) {
59 | border-right: 1px solid rgb(229 231 235);
60 | }
61 |
62 | .Button:last-child {
63 | border-top-right-radius: 4px;
64 | border-bottom-right-radius: 4px;
65 | border-right: 1px solid white;
66 | }
67 |
68 | @media (min-width: 640px) {
69 | .InputField {
70 | font-size: 12px;
71 | line-height: 18px;
72 | }
73 |
74 | .Button {
75 | height: 24px;
76 | width: 30px;
77 | }
78 | }
--------------------------------------------------------------------------------
/app/_components/PageTitle.tsx:
--------------------------------------------------------------------------------
1 | export default function PageTitle({ children }: { children: React.ReactNode }) {
2 | return (
3 |
4 | {children}
5 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/app/_components/ParticleAnimation/index.tsx:
--------------------------------------------------------------------------------
1 | // Fun particle animation
2 | // Rather than importing whole library, just using component from here:
3 | // https://uiball.com/ldrs/
4 |
5 | import styles from './particleAnimation.module.css';
6 |
7 | export default function ParticleAnimation() {
8 | return (
9 |
10 | {/* Generate particles dynamically if you need more or fewer particles */}
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/app/_components/PendingImagesPanel/PendingImagesPanel_ClearButton.tsx:
--------------------------------------------------------------------------------
1 | import Button from '../Button'
2 | import { IconClearAll } from '@tabler/icons-react'
3 | import { PendingImagesStore } from '@/app/_stores/PendingImagesStore'
4 | import { JobStatus } from '@/app/_types/ArtbotTypes'
5 | import { deleteJobFromDexie } from '@/app/_db/jobTransactions'
6 | import DropdownMenu from '../DropdownMenu'
7 | import { MenuHeader, MenuItem } from '@szhsin/react-menu'
8 |
9 | export default function ClearButton() {
10 | const clearDone = () => {
11 | PendingImagesStore.set((state) => ({
12 | pendingImages: state.pendingImages.filter(
13 | (job) => job.status !== JobStatus.Done
14 | )
15 | }))
16 | }
17 |
18 | const clearWaiting = () => {
19 | PendingImagesStore.set((state) => ({
20 | pendingImages: state.pendingImages.filter((job) => {
21 | if (job.status === JobStatus.Waiting) {
22 | deleteJobFromDexie(job.artbot_id)
23 | }
24 | return job.status !== JobStatus.Waiting
25 | })
26 | }))
27 | }
28 |
29 | const clearError = () => {
30 | PendingImagesStore.set((state) => ({
31 | pendingImages: state.pendingImages.filter((job) => {
32 | if (job.status === JobStatus.Error) {
33 | deleteJobFromDexie(job.artbot_id)
34 | }
35 | return job.status !== JobStatus.Error
36 | })
37 | }))
38 | }
39 |
40 | const clearAll = () => {
41 | clearDone()
42 | clearWaiting()
43 | clearError()
44 | }
45 |
46 | return (
47 | {}}
52 | style={{ height: '38px', width: '38px' }}
53 | >
54 |
55 |
56 | }
57 | shift={-120}
58 | >
59 | Clear pending images
60 | All
61 | Done
62 | Pending
63 | Error
64 |
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/app/_components/Portal.tsx:
--------------------------------------------------------------------------------
1 | import { createPortal } from 'react-dom'
2 |
3 | export default function Portal({ children }: { children: React.ReactNode }) {
4 | return createPortal(children, document.body)
5 | }
6 |
--------------------------------------------------------------------------------
/app/_components/PromptWarning.tsx:
--------------------------------------------------------------------------------
1 | import { IconAlertTriangle, IconExclamationCircle } from '@tabler/icons-react'
2 | import { PromptError } from '../_hooks/usePromptInputValidation'
3 | import Button from './Button'
4 | import NiceModal from '@ebay/nice-modal-react'
5 |
6 | interface PromptWarningProps {
7 | errors: PromptError[]
8 | }
9 |
10 | export default function PromptWarning({ errors }: PromptWarningProps) {
11 | return (
12 |
13 |
Warnings
14 |
15 | {errors.map((error) => (
16 |
23 |
24 | {error.type === 'warning' && }
25 | {error.type === 'critical' && (
26 |
27 | )}
28 | {error.message}
29 |
30 | {error.type === 'warning' && (
31 |
32 | (Optional) You can still continue.
33 |
34 | )}
35 | {error.type === 'critical' && (
36 |
37 | (Required) You must fix this error in order to continue.
38 |
39 | )}
40 |
41 | ))}
42 |
43 |
44 | {
46 | NiceModal.remove('modal')
47 | }}
48 | >
49 | Close
50 |
51 |
52 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/app/_components/SectionTitle.tsx:
--------------------------------------------------------------------------------
1 | import { IconLink } from '@tabler/icons-react';
2 |
3 | export default function SectionTitle({
4 | anchor,
5 | children
6 | }: {
7 | anchor?: string;
8 | children: React.ReactNode;
9 | }) {
10 | return (
11 |
15 | {anchor && (
16 |
17 |
18 |
19 | )}
20 | {children}
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/app/_components/Spinner/index.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from 'react'
2 | import styles from './spinner.module.css'
3 |
4 | export default function Spinner({
5 | style,
6 | size = 50
7 | }: {
8 | size?: number
9 | style?: CSSProperties
10 | }) {
11 | return (
12 |
19 |
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/app/_components/Spinner/spinner.module.css:
--------------------------------------------------------------------------------
1 | /* SpinnerV2.module.css */
2 | @keyframes rotate {
3 | 100% {
4 | transform: rotate(360deg);
5 | }
6 | }
7 |
8 | @keyframes dash {
9 | 0% {
10 | stroke-dasharray: 1, 150;
11 | stroke-dashoffset: 0;
12 | }
13 |
14 | 50% {
15 | stroke-dasharray: 90, 150;
16 | stroke-dashoffset: -35;
17 | }
18 |
19 | 100% {
20 | stroke-dasharray: 90, 150;
21 | stroke-dashoffset: -124;
22 | }
23 | }
24 |
25 | .styledSVG {
26 | animation: rotate 2s linear infinite;
27 | z-index: 2;
28 | }
29 |
30 | .styledCircle {
31 | stroke: #14b8a6;
32 | stroke-linecap: round;
33 | animation: dash 1.5s ease-in-out infinite;
34 | }
--------------------------------------------------------------------------------
/app/_components/Switch/index.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from 'react';
2 | import clsx from 'clsx';
3 | import styles from './switch.module.css';
4 |
5 | interface SwitchProps {
6 | checked: boolean;
7 | disabled?: boolean;
8 | onChange(): void;
9 | }
10 |
11 | const Switch = ({ checked, disabled, onChange = () => {} }: SwitchProps) => {
12 | const id = useId();
13 |
14 | return (
15 | <>
16 | {
19 | if (disabled) return;
20 | onChange();
21 | }}
22 | className={styles['react-switch-checkbox']}
23 | id={id}
24 | type="checkbox"
25 | />
26 |
34 |
40 |
41 | >
42 | );
43 | };
44 |
45 | export default Switch;
46 |
--------------------------------------------------------------------------------
/app/_components/Switch/switch.module.css:
--------------------------------------------------------------------------------
1 | .react-switch-label,
2 | .react-switch-label .react-switch-button {
3 | --circle-size: 26px;
4 | --container-height: 30px;
5 | --container-width: 56px;
6 | }
7 |
8 | @media (min-width: 640px) {
9 |
10 | .react-switch-label,
11 | .react-switch-label .react-switch-button {
12 | --circle-size: 20px;
13 | --container-height: 24px;
14 | --container-width: 48px;
15 | }
16 | }
17 |
18 | @media (min-width: 800px) {
19 |
20 | .react-switch-label,
21 | .react-switch-label .react-switch-button {
22 | --circle-size: 16px;
23 | --container-height: 20px;
24 | --container-width: 40px;
25 | }
26 | }
27 |
28 | .react-switch-checkbox {
29 | height: 0;
30 | width: 0;
31 | visibility: hidden;
32 | }
33 |
34 | .react-switch-label {
35 | display: flex;
36 | align-items: center;
37 | justify-content: space-between;
38 | cursor: pointer;
39 | width: var(--container-width);
40 | height: var(--container-height);
41 | background: #C0C0C0;
42 | border-radius: 100px;
43 | position: relative;
44 | transition: background-color .2s;
45 | }
46 |
47 | .react-switch-label .react-switch-button {
48 | content: '';
49 | position: absolute;
50 | top: 2px;
51 | left: 2px;
52 | width: var(--circle-size);
53 | height: var(--circle-size);
54 | border-radius: 45px;
55 | transition: 0.2s;
56 | background: #fff;
57 | box-shadow: 0 0 2px 0 rgba(10, 10, 10, 0.29);
58 | }
59 |
60 | .Checked {
61 | background: rgb(1, 171, 171);
62 | }
63 |
64 | .react-switch-checkbox:checked+.react-switch-label .react-switch-button {
65 | left: calc(100% - 2px);
66 | transform: translateX(-100%);
67 | }
68 |
69 | /* .react-switch-label:active .react-switch-button {
70 | width: 40px;
71 | } */
72 |
73 | .DisabledLabel {
74 | cursor: inherit;
75 | background: #C0C0C0;
76 | }
77 |
78 | .DisabledLabel:active {
79 | cursor: inherit;
80 | background: #C0C0C0;
81 | }
82 |
83 | .react-switch-label .DisabledButton {
84 | background: #E5E5E5;
85 | }
86 |
87 | .react-switch-label:active .DisabledButton {
88 | width: var(--circle-size);
89 | }
--------------------------------------------------------------------------------
/app/_components/Text/index.tsx:
--------------------------------------------------------------------------------
1 | interface TextProps {
2 | children?: React.ReactNode;
3 | }
4 |
5 | const Text = ({ children }: TextProps) => {
6 | return {children}
;
7 | };
8 |
9 | export default Text;
10 |
--------------------------------------------------------------------------------
/app/_components/TotalImagesGenerated/TotalImagesGenerated.tsx:
--------------------------------------------------------------------------------
1 | import TotalImagesGeneratedLive from './TotalImagesGeneratedLive';
2 | const statusApi = process.env.ARTBOT_STATUS_API;
3 |
4 | async function getImageCount() {
5 | if (!statusApi) {
6 | return 0;
7 | }
8 | const controller = new AbortController();
9 | const timeoutId = setTimeout(() => controller.abort(), 2000);
10 |
11 | try {
12 | const response = await fetch(`${statusApi}/images/total`, {
13 | next: { revalidate: 5 },
14 | signal: controller.signal
15 | });
16 | const data = await response.json();
17 | clearTimeout(timeoutId);
18 | return data.totalCount || 0;
19 | } catch (error) {
20 | clearTimeout(timeoutId);
21 | console.log('Failed to fetch total image count:', error);
22 | return 0;
23 | }
24 | }
25 |
26 | export default async function TotalImagesGenerated() {
27 | const initialCount = await getImageCount();
28 |
29 | if (initialCount === 0) {
30 | return null;
31 | }
32 |
33 | return ;
34 | }
35 |
--------------------------------------------------------------------------------
/app/_components/TotalImagesGenerated/TotalImagesGeneratedLive.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { appBasepath } from '@/app/_utils/browserUtils';
4 | import { useEffect, useState } from 'react';
5 |
6 | export default function TotalImagesGeneratedLive({
7 | initialCount
8 | }: {
9 | initialCount: number;
10 | }) {
11 | const [imageCount, setImageCount] = useState(initialCount);
12 |
13 | const fetchImageCount = async () => {
14 | try {
15 | const response = await fetch(
16 | `${appBasepath()}/api/status/counter/images`,
17 | {
18 | cache: 'no-store'
19 | }
20 | );
21 | const data = await response.json();
22 | if (data.totalCount) setImageCount(data.totalCount);
23 | } catch (error) {
24 | console.error('Error fetching image count:', error);
25 | }
26 | };
27 |
28 | useEffect(() => {
29 | fetchImageCount();
30 |
31 | const interval = setInterval(() => {
32 | fetchImageCount();
33 | }, 2000);
34 |
35 | return () => {
36 | clearInterval(interval);
37 | };
38 | }, []);
39 |
40 | if (imageCount === 0) {
41 | return null;
42 | }
43 |
44 | return (
45 |
46 | ArtBot has been used to generate{' '}
47 | {imageCount.toLocaleString()} {' '}
48 | images.
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/app/_controllers/pendingJobs/index.ts:
--------------------------------------------------------------------------------
1 | import { checkForWaitingJobs } from './checkForWaitingJobs'
2 | import { checkPendingJobs } from './checkPendingJobs'
3 |
4 | export const initJobController = () => {
5 | checkForWaitingJobs()
6 | checkPendingJobs()
7 |
8 | setInterval(() => {
9 | checkForWaitingJobs()
10 | }, 250)
11 |
12 | setInterval(() => {
13 | checkPendingJobs()
14 | }, 2000)
15 | }
16 |
--------------------------------------------------------------------------------
/app/_controllers/pendingJobs/loadPendingImages.ts:
--------------------------------------------------------------------------------
1 | import { fetchPendingJobsByStatusFromDexie } from '@/app/_db/hordeJobs'
2 | import { addPendingImageToAppState } from '@/app/_stores/PendingImagesStore'
3 | import { JobStatus } from '@/app/_types/ArtbotTypes'
4 |
5 | // Handles loading any pending images from Dexie on initial app load.
6 | export const loadPendingImagesFromDexie = async () => {
7 | const jobs = await fetchPendingJobsByStatusFromDexie([
8 | JobStatus.Waiting,
9 | JobStatus.Queued,
10 | JobStatus.Requested,
11 | JobStatus.Processing,
12 | JobStatus.Error
13 | ])
14 |
15 | jobs.forEach((job) => {
16 | addPendingImageToAppState(job)
17 | })
18 | }
19 |
--------------------------------------------------------------------------------
/app/_controllers/pendingJobs/updatePendingImage.ts:
--------------------------------------------------------------------------------
1 | import { ArtBotHordeJob } from '@/app/_data-models/ArtBotHordeJob'
2 | import { updateHordeJobById } from '@/app/_db/hordeJobs'
3 | import {
4 | getPendingImageByIdFromAppState,
5 | updatePendingImageInAppState
6 | } from '@/app/_stores/PendingImagesStore'
7 | import { JobStatus } from '@/app/_types/ArtbotTypes'
8 |
9 | /**
10 | * Update pending image in app state (for quick lookups) and in IndexedDB (persistent storage)
11 | * @param artbot_id
12 | * @param options
13 | */
14 | export const updatePendingImage = async (
15 | artbot_id: string,
16 | options: Partial
17 | ) => {
18 | const pendingImageDataToUpdate = getPendingImageByIdFromAppState(artbot_id)
19 |
20 | // Add a null check here
21 | if (pendingImageDataToUpdate) {
22 | if (
23 | options.status &&
24 | options.status === JobStatus.Queued &&
25 | !pendingImageDataToUpdate.horde_received_timestamp
26 | ) {
27 | options.horde_received_timestamp = Date.now()
28 | }
29 |
30 | if (
31 | options.status &&
32 | options.status === JobStatus.Done &&
33 | !pendingImageDataToUpdate.horde_completed_timestamp
34 | ) {
35 | options.horde_completed_timestamp = Date.now()
36 | }
37 |
38 | if (options.wait_time) {
39 | // If init_wait_time is null or the new wait_time is greater, update init_wait_time
40 | if (
41 | pendingImageDataToUpdate.init_wait_time === null ||
42 | options.wait_time > pendingImageDataToUpdate.init_wait_time
43 | ) {
44 | options.init_wait_time = options.wait_time
45 | }
46 | }
47 |
48 | // IndexedDb update should run first before app state update
49 | // Due to cascading affect on PendingImagesPanel
50 | await updateHordeJobById(artbot_id, {
51 | ...options
52 | })
53 |
54 | updatePendingImageInAppState(artbot_id, {
55 | ...options
56 | })
57 | } else {
58 | console.error(`No pending image found with id: ${artbot_id}`)
59 | // You might want to handle this case differently depending on your application's needs
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/app/_controllers/toastController.ts:
--------------------------------------------------------------------------------
1 | import toast from 'react-hot-toast'
2 |
3 | interface ToastController {
4 | message: string | React.ReactNode
5 | id?: string
6 | type?: 'success' | 'error'
7 | timeout?: number
8 | }
9 |
10 | interface ToastOptions {
11 | duration: number
12 | id?: string
13 | }
14 |
15 | export const toastController = ({
16 | message,
17 | id = '',
18 | type = 'success',
19 | timeout = 3000
20 | }: ToastController) => {
21 | const options: ToastOptions = {
22 | duration: timeout
23 | }
24 |
25 | if (id) {
26 | options.id = id
27 | }
28 |
29 | // @ts-expect-error We can pass in a string or a React node
30 | toast[type](message, options)
31 | }
32 |
--------------------------------------------------------------------------------
/app/_data-models/AppConstants.ts:
--------------------------------------------------------------------------------
1 | export class AppConstants {
2 | static AI_HORDE_ANON_KEY = '0000000000'
3 |
4 | static AI_HORDE_PROD_URL =
5 | process.env.NEXT_HORDE_API_HOST || 'https://aihorde.net'
6 |
7 | static CIVITAI_API_TIMEOUT_MS = 15000
8 |
9 | /**
10 | * Maximum number of simulatenous jobs that can be running on the Horde.
11 | * Should be lower for anon users. Logged in users should be able to increase this in settings app.
12 | */
13 | static MAX_CONCURRENT_JOBS = 10
14 |
15 | /**
16 | * Sets length of nanoid output used across entire web app.
17 | */
18 | static NANO_ID_LENGTH = 13
19 |
20 | static IMAGE_UPLOAD_TEMP_ID = '__TEMP_USER_IMG_UPLOAD__'
21 |
22 | /**
23 | * Maximum supported resolution for image requests to the AI Horde
24 | */
25 | static MAX_IMAGE_PIXELS = 4194304
26 |
27 | static MAX_LORA_SIZE_MB = 220
28 |
29 | static MAX_LORAS = 5
30 |
31 | /**
32 | * Speed of the typewriter effect in milliseconds
33 | */
34 | static TYPING_SPEED_MS = 25;
35 | }
36 |
--------------------------------------------------------------------------------
/app/_data-models/ClientHeader.ts:
--------------------------------------------------------------------------------
1 | export const clientHeader = () => {
2 | return `ArtBot:v.2:(discord)rockbandit#4910`
3 | }
4 |
--------------------------------------------------------------------------------
/app/_data-models/HordeTeams.ts:
--------------------------------------------------------------------------------
1 | import { SelectOption } from '../_components/ComboBox';
2 | import { AppConstants } from './AppConstants';
3 |
4 | interface TeamApiResponse {
5 | name: string;
6 | id: string;
7 | }
8 |
9 | export let TeamsCache: SelectOption[] = [];
10 | export let lastFetchTime = 0;
11 | const CACHE_TIMEOUT = 1000 * 60 * 10; // 10 minutes
12 |
13 | export const fetchTeams = async (): Promise => {
14 | const now = Date.now();
15 | // Return cache if it exists and hasn't expired
16 | if (TeamsCache.length > 0 && now - lastFetchTime < CACHE_TIMEOUT) {
17 | return TeamsCache;
18 | }
19 |
20 | // Only fetch if we're past the timeout
21 | if (now - lastFetchTime >= CACHE_TIMEOUT) {
22 | try {
23 | const res = await fetch(
24 | `${AppConstants.AI_HORDE_PROD_URL}/api/v2/teams`,
25 | {
26 | cache: 'no-store'
27 | }
28 | );
29 | const details = (await res.json()) as TeamApiResponse[];
30 |
31 | if (Array.isArray(details)) {
32 | const formatTeams: SelectOption[] = details.map((team) => {
33 | return {
34 | label: team.name,
35 | value: team.id
36 | };
37 | });
38 |
39 | formatTeams.sort((a, b) => {
40 | // Sort by online status first (true values first)
41 | if (a.label < b.label) {
42 | return -1;
43 | }
44 | if (a.label > b.label) {
45 | return 1;
46 | }
47 | return 0;
48 | });
49 |
50 | formatTeams.unshift({
51 | label: 'None',
52 | // @ts-expect-error null is okay here
53 | value: null
54 | });
55 |
56 | // Only update cache and timestamp on successful fetch and processing
57 | TeamsCache = formatTeams;
58 | lastFetchTime = now;
59 |
60 | return formatTeams;
61 | }
62 | } catch (error) {
63 | console.error('Error fetching teams:', error);
64 | }
65 | }
66 |
67 | // If we couldn't fetch new data and cache exists, return cache even if expired
68 | if (TeamsCache.length > 0) {
69 | return TeamsCache;
70 | }
71 |
72 | // Return empty array as fallback if no data available
73 | return [] as SelectOption[];
74 | };
75 |
--------------------------------------------------------------------------------
/app/_data-models/ImageFile_Dexie.ts:
--------------------------------------------------------------------------------
1 | import { GenMetadata } from '../_types/HordeTypes'
2 |
3 | export enum ImageStatus {
4 | CENSORED = 'censored',
5 | ERROR = 'error',
6 | OK = 'ok',
7 | PENDING = 'pending'
8 | }
9 |
10 | export enum ImageType {
11 | IMAGE = 'image', // Default image type. e.g., response from AI Horde
12 | THUMB = 'thumbnail',
13 | CONTROLNET = 'controlnet',
14 | SOURCE = 'source', // Uploaded img for img2img or ControlNet
15 | MASK = 'mask',
16 | UPSCALE = 'upscale'
17 | }
18 |
19 | export interface ImageBlobBuffer {
20 | type: string
21 | arrayBuffer: ArrayBuffer
22 | size: number
23 | id: string // unique ID for memo comparison
24 | }
25 |
26 | export interface ImageFileInterface {
27 | id?: number
28 | artbot_id: string
29 | horde_id: string
30 | image_id: string
31 | imageType?: ImageType
32 | imageStatus?: ImageStatus
33 | sampler?: string
34 | model?: string
35 | imageBlobBuffer?: ImageBlobBuffer | null
36 | gen_metadata?: GenMetadata[]
37 | strength?: number | null // Used when adding multiple images (e.g., remix)
38 | seed?: string
39 | worker_id?: string
40 | worker_name?: string
41 | kudos?: number | string
42 | apiResponse: string
43 | }
44 |
45 | class ImageFile implements ImageFileInterface {
46 | // Indexed fields
47 | artbot_id: string = ''
48 | horde_id: string = ''
49 | image_id: string = ''
50 | imageType: ImageType = ImageType.IMAGE
51 | imageStatus: ImageStatus = ImageStatus.PENDING
52 | model: string = ''
53 |
54 | // Other fields
55 | imageBlob?: ImageBlobBuffer | null = null
56 | gen_metadata?: GenMetadata[] = []
57 | seed: string = ''
58 | strength: number | null = null
59 | worker_id: string = ''
60 | worker_name: string = ''
61 | apiResponse: string = ''
62 |
63 | constructor(params: Partial) {
64 | Object.assign(this, params)
65 | }
66 | }
67 |
68 | export { ImageFile }
69 |
--------------------------------------------------------------------------------
/app/_data-models/ManageWorker.ts:
--------------------------------------------------------------------------------
1 | import { WorkerDetails } from '../_types/HordeTypes';
2 |
3 | export class ManageWorker {
4 | static getBadgeColor = (worker: WorkerDetails) => {
5 | const workerState = ManageWorker.getWorkerState(worker);
6 | let workerBadgeColor = 'red';
7 |
8 | if (workerState === 'active') {
9 | workerBadgeColor = 'green';
10 | }
11 |
12 | if (workerState === 'paused') {
13 | workerBadgeColor = 'orange';
14 | }
15 |
16 | if (workerState === 'loading') {
17 | workerBadgeColor = 'gray';
18 | }
19 |
20 | return workerBadgeColor;
21 | };
22 |
23 | static getWorkerState = (worker: WorkerDetails) => {
24 | if (worker.online && !worker.maintenance_mode) {
25 | return 'active';
26 | }
27 |
28 | if (worker.online && worker.maintenance_mode) {
29 | return 'paused';
30 | }
31 |
32 | if (worker.loading) {
33 | return 'loading';
34 | }
35 |
36 | if (!worker.online) {
37 | return 'offline';
38 | }
39 | };
40 | }
41 |
--------------------------------------------------------------------------------
/app/_db/dexie.ts:
--------------------------------------------------------------------------------
1 | import Dexie, { Table } from 'dexie'
2 |
3 | import {
4 | AppSettingsTable,
5 | FavoriteImage,
6 | ImageEnhancementModulesTable,
7 | ImageRequest,
8 | PromptsHistory,
9 | PromptsJobMap
10 | } from '@/app/_types/ArtbotTypes'
11 | import { ImageFileInterface } from '../_data-models/ImageFile_Dexie'
12 | import { ArtBotHordeJob } from '../_data-models/ArtBotHordeJob'
13 |
14 | class ArtBot_v2 extends Dexie {
15 | public declare appSettings: Table
16 | public declare favorites: Table
17 | public declare hordeJobs: Table
18 | public declare imageEnhancementModules: Table<
19 | ImageEnhancementModulesTable,
20 | number
21 | >
22 | public declare imageFiles: Table
23 | public declare imageRequests: Table
24 | public declare promptsHistory: Table
25 | public declare promptsJobMap: Table
26 |
27 | public constructor() {
28 | super(process.env.NEXT_PUBLIC_DEXIE_DB as string)
29 | this.version(1).stores({
30 | appSettings: '++id, &key',
31 | favorites: '++id, artbot_id, image_id, favorited',
32 | hordeJobs: '++id, artbot_id, job_id, horde_id, status',
33 | imageEnhancementModules:
34 | '++id, model_id, modifier, type, [modifier+type], [model_id+modifier], [model_id+type]',
35 | imageFiles:
36 | '++id, artbot_id, horde_id, image_id, imageType, imageStatus, [artbot_id+imageType], [image_id+imageType], [imageStatus+imageType], model, sampler',
37 | imageRequests: '++id, artbot_id, jobType',
38 | promptsHistory:
39 | '++id, artbot_id, hash_id, *promptWords, promptType, favorited, [promptType+favorited]',
40 | promptsJobMap: '++id, artbot_id, prompt_id'
41 | })
42 |
43 | this.version(2).stores({
44 | imageEnhancementModules:
45 | '++id, model_id, modifier, type, timestamp, [modifier+type], [modifier+type+timestamp], [model_id+modifier], [model_id+type]'
46 | }).upgrade(() => {
47 | // No data migration needed, just adding indexes
48 | })
49 | }
50 | }
51 |
52 | const db: ArtBot_v2 = new ArtBot_v2()
53 |
54 | export const initDexie = () => {
55 | if (db) {
56 | console.log(`ArtBot IndexedDB initialized.`)
57 | }
58 | }
59 |
60 | export { db }
61 |
--------------------------------------------------------------------------------
/app/_db/favorites.ts:
--------------------------------------------------------------------------------
1 | import { db } from './dexie'
2 |
3 | export const updateFavoriteInDexie = async (
4 | artbot_id: string,
5 | image_id: string,
6 | favorited: boolean
7 | ) => {
8 | try {
9 | await db.transaction('rw', db.favorites, async () => {
10 | const status = (await getFavoriteFromDexie(image_id)) || {}
11 |
12 | if ('favorited' in status) {
13 | await db.favorites.where({ image_id }).modify({ favorited })
14 | } else {
15 | await db.favorites.add({ artbot_id, image_id, favorited })
16 | }
17 | })
18 | } catch (error) {
19 | console.error('Transaction failed: ', error)
20 | throw error
21 | }
22 | }
23 |
24 | export const deleteFavoriteFromDexie = async (image_id: string) => {
25 | try {
26 | await db.transaction('rw', db.favorites, async () => {
27 | await db.favorites.where({ image_id }).delete()
28 | })
29 | } catch (error) {
30 | console.error('Transaction failed: ', error)
31 | throw error // Rethrow the error to be handled by the caller
32 | }
33 | }
34 |
35 | export const getFavoriteFromDexie = async (image_id: string) => {
36 | if (!image_id) return {}
37 |
38 | return await db.favorites.where({ image_id }).first()
39 | }
40 |
--------------------------------------------------------------------------------
/app/_db/imageRequests.ts:
--------------------------------------------------------------------------------
1 | import { ImageRequest } from '../_types/ArtbotTypes'
2 | import { db } from './dexie'
3 |
4 | export const addImageRequestToDexie = async (imageRequest: ImageRequest) => {
5 | try {
6 | await db.transaction('rw', db.imageRequests, async () => {
7 | await db.imageRequests.add(imageRequest)
8 | })
9 | } catch (error) {
10 | console.error('Transaction failed: ', error)
11 | throw error
12 | }
13 | }
14 |
15 | export const getImageRequestsFromDexieById = async (artbotIds: string[]) => {
16 | let files = await db.imageRequests
17 | .where('artbot_id')
18 | .anyOf(artbotIds)
19 | .toArray()
20 |
21 | // @ts-expect-error id is returned from Dexie
22 | files = files.sort((a, b) => a.id - b.id)
23 |
24 | return files
25 | }
26 |
--------------------------------------------------------------------------------
/app/_hooks/useCarouselDots.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from 'react'
2 | import { EmblaCarouselType } from 'embla-carousel'
3 |
4 | type UseDotButtonType = {
5 | selectedIndex: number
6 | scrollSnaps: number[]
7 | onDotButtonClick: (index: number) => void
8 | }
9 |
10 | export const useDotButton = (
11 | emblaApi: EmblaCarouselType | undefined
12 | ): UseDotButtonType => {
13 | const [selectedIndex, setSelectedIndex] = useState(0)
14 | const [scrollSnaps, setScrollSnaps] = useState([])
15 |
16 | const onDotButtonClick = useCallback(
17 | (index: number) => {
18 | if (!emblaApi) return
19 | emblaApi.scrollTo(index)
20 | },
21 | [emblaApi]
22 | )
23 |
24 | const onInit = useCallback((emblaApi: EmblaCarouselType) => {
25 | setScrollSnaps(emblaApi.scrollSnapList())
26 | }, [])
27 |
28 | const onSelect = useCallback((emblaApi: EmblaCarouselType) => {
29 | setSelectedIndex(emblaApi.selectedScrollSnap())
30 | }, [])
31 |
32 | useEffect(() => {
33 | if (!emblaApi) return
34 |
35 | onInit(emblaApi)
36 | onSelect(emblaApi)
37 | emblaApi.on('reInit', onInit)
38 | emblaApi.on('reInit', onSelect)
39 | emblaApi.on('select', onSelect)
40 | }, [emblaApi, onInit, onSelect])
41 |
42 | return {
43 | selectedIndex,
44 | scrollSnaps,
45 | onDotButtonClick
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/app/_hooks/useContainerWidth.tsx:
--------------------------------------------------------------------------------
1 | // useContainerWidth.ts
2 | import { useState, useEffect, RefObject } from 'react'
3 |
4 | export const useContainerWidth = (containerRef: RefObject) => {
5 | const [width, setWidth] = useState(null)
6 |
7 | useEffect(() => {
8 | const handleResize = () => {
9 | if (containerRef.current) {
10 | setWidth(containerRef.current.offsetWidth)
11 | } else {
12 | setWidth(window.innerWidth)
13 | }
14 | }
15 |
16 | handleResize()
17 | window.addEventListener('resize', handleResize)
18 |
19 | return () => {
20 | window.removeEventListener('resize', handleResize)
21 | }
22 | }, [containerRef])
23 |
24 | return width
25 | }
26 |
--------------------------------------------------------------------------------
/app/_hooks/useEffectOnce.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from 'react'
2 |
3 | export const useEffectOnce = (effect: () => void | (() => void)) => {
4 | const effectFn = useRef<() => void | (() => void)>(effect)
5 | const destroyFn = useRef void)>()
6 | const effectCalled = useRef(false)
7 | const rendered = useRef(false)
8 | const [, setVal] = useState(0)
9 |
10 | if (effectCalled.current) {
11 | rendered.current = true
12 | }
13 |
14 | useEffect(() => {
15 | // only execute the effect first time around
16 | if (!effectCalled.current) {
17 | destroyFn.current = effectFn.current()
18 | effectCalled.current = true
19 | }
20 |
21 | // this forces one render after the effect is run
22 | setVal((val) => val + 1)
23 |
24 | return () => {
25 | // if the comp didn't render since the useEffect was called,
26 | // we know it's the dummy React cycle
27 | if (!rendered.current) {
28 | return
29 | }
30 |
31 | // otherwise this is not a dummy destroy, so call the destroy func
32 | if (destroyFn.current) {
33 | destroyFn.current()
34 | }
35 | }
36 | }, [])
37 | }
38 |
--------------------------------------------------------------------------------
/app/_hooks/useFavorite.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import {
3 | getFavoriteFromDexie,
4 | updateFavoriteInDexie
5 | } from '@/app/_db/favorites'
6 |
7 | export default function useFavorite(
8 | artbot_id: string,
9 | image_id: string
10 | ): [boolean, () => void] {
11 | const [isFavorite, setIsFavorite] = useState(false)
12 |
13 | const toggleFavorite = async () => {
14 | if (!isFavorite) {
15 | await updateFavoriteInDexie(artbot_id, image_id, true)
16 | } else {
17 | await updateFavoriteInDexie(artbot_id, image_id, false)
18 | }
19 |
20 | setIsFavorite(!isFavorite)
21 | }
22 |
23 | useEffect(() => {
24 | const getFavorite = async () => {
25 | const status = ((await getFavoriteFromDexie(image_id)) || {}) as {
26 | favorited: boolean
27 | }
28 | if ('favorited' in status) {
29 | setIsFavorite(status.favorited)
30 | }
31 | }
32 |
33 | getFavorite()
34 | }, [image_id])
35 |
36 | return [isFavorite, toggleFavorite]
37 | }
38 |
--------------------------------------------------------------------------------
/app/_hooks/useFetch.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useCallback } from 'react'
2 |
3 | // Custom hook for fetching data with a timeout and abort functionality
4 | function useFetchWithTimeout() {
5 | const [data, setData] = useState(null)
6 | const [loading, setLoading] = useState(false)
7 | const [error, setError] = useState(null)
8 | const controllerRef = useRef(null)
9 | const timeoutIdRef = useRef(null)
10 |
11 | const fetchData = useCallback(
12 | async (url: string, timeout: number = 8000) => {
13 | setLoading(true)
14 | setError(null)
15 | setData(null)
16 |
17 | const controller = new AbortController()
18 | controllerRef.current = controller
19 | const signal = controller.signal
20 |
21 | // Set a timeout to abort the request and distinguish it as a timeout error
22 | timeoutIdRef.current = window.setTimeout(() => {
23 | controller.abort()
24 | setError('Request timed out.')
25 | }, timeout)
26 |
27 | try {
28 | const response = await fetch(url, { signal })
29 |
30 | if (!response.ok) {
31 | throw new Error(`HTTP error! Status: ${response.status}`)
32 | }
33 |
34 | const data = await response.json()
35 | setData(data)
36 | // @ts-expect-error "never" type is fine here
37 | } catch (err: never) {
38 | // Only set the error if it wasn't already set by a timeout
39 | if (error === null) {
40 | if (err.name === 'AbortError') {
41 | setError('Fetch request was canceled by the user.')
42 | } else {
43 | setError('Fetch error: ' + err.message)
44 | }
45 | }
46 | } finally {
47 | setLoading(false)
48 | // Clear the timeout after the fetch is completed or aborted
49 | if (timeoutIdRef.current) {
50 | clearTimeout(timeoutIdRef.current)
51 | timeoutIdRef.current = null
52 | }
53 | controllerRef.current = null
54 | }
55 | },
56 | [error]
57 | )
58 |
59 | const cancelFetch = useCallback(() => {
60 | if (controllerRef.current) {
61 | controllerRef.current.abort()
62 | setError('Fetch request was canceled by the user.')
63 | }
64 | }, [])
65 |
66 | return { data, loading, error, fetchData, cancelFetch }
67 | }
68 |
69 | export default useFetchWithTimeout
70 |
--------------------------------------------------------------------------------
/app/_hooks/useHordeApiKey.tsx:
--------------------------------------------------------------------------------
1 | import { clientHeader } from '../_data-models/ClientHeader'
2 | import { AppSettings } from '../_data-models/AppSettings'
3 | import { HordeUser } from '../_types/HordeTypes'
4 | import { updateUser } from '../_stores/UserStore'
5 | import { isUuid } from '../_utils/stringUtils'
6 |
7 | interface ErrorResponse {
8 | message: string
9 | }
10 |
11 | // type guard functions
12 | function isErrorResponse(response: ErrorResponse): response is ErrorResponse {
13 | return (response as ErrorResponse).message !== undefined
14 | }
15 |
16 | function isUserResponse(response: HordeUser): response is HordeUser {
17 | return (response as HordeUser).username !== undefined
18 | }
19 |
20 | export default function useHordeApiKey() {
21 | // const { userDetails } = useStore(UserStore)
22 |
23 | const handleLogin = async (apikey: string = '') => {
24 | if (!apikey.trim()) {
25 | return { success: false }
26 | }
27 |
28 | const isSharedKey = isUuid(apikey)
29 | const userDetailsApi = isSharedKey
30 | ? `https://aihorde.net/api/v2/sharedkeys/${apikey}`
31 | : `https://aihorde.net/api/v2/find_user`
32 |
33 | try {
34 | const res = await fetch(userDetailsApi, {
35 | cache: 'no-store',
36 | headers: {
37 | apikey: apikey,
38 | 'Client-Agent': clientHeader(),
39 | 'Content-Type': 'application/json'
40 | }
41 | })
42 |
43 | const data = (await res.json()) || {}
44 |
45 | if (isErrorResponse(data)) {
46 | return { success: false, message: data.message }
47 | } else if (isUserResponse(data)) {
48 | if (!isSharedKey) {
49 | console.log(`Successfully logged in as ${data.username}`)
50 | AppSettings.set('apiKey', apikey)
51 | } else {
52 | console.log(`Successfully logged in with shared key: ${apikey}`)
53 | }
54 | updateUser(data)
55 | } else {
56 | console.warn('useHordeApiKey: Unknown data structure received', data)
57 | return { success: false, message: 'Unknown data structure received' }
58 | }
59 |
60 | return { success: true, data }
61 | } catch (err) {
62 | console.warn(`useHordeApiKey: ${err}`)
63 | return { success: false, message: (err as Error).message }
64 | }
65 | }
66 |
67 | return [handleLogin]
68 | }
69 |
--------------------------------------------------------------------------------
/app/_hooks/useImageDetails.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from 'react'
2 | import { getImageDetailsFromDexie } from '../_db/jobTransactions'
3 | import { ImageFileInterface } from '../_data-models/ImageFile_Dexie'
4 | import { ImageRequest } from '../_types/ArtbotTypes'
5 | import { ArtBotHordeJob } from '../_data-models/ArtBotHordeJob'
6 |
7 | export interface JobDetails {
8 | jobDetails: ArtBotHordeJob
9 | imageFile: ImageFileInterface
10 | imageRequest: ImageRequest
11 | }
12 |
13 | export default function useImageDetails(image_id: string) {
14 | const [imageDetails, setImageDetails] = useState()
15 |
16 | const fetchImageDetails = useCallback(async () => {
17 | if (!image_id) return
18 |
19 | const data = await getImageDetailsFromDexie(image_id)
20 | if (!data) return
21 | setImageDetails(data as JobDetails)
22 | }, [image_id])
23 |
24 | useEffect(() => {
25 | fetchImageDetails()
26 | }, [fetchImageDetails, image_id])
27 |
28 | return [imageDetails]
29 | }
30 |
--------------------------------------------------------------------------------
/app/_hooks/useIntersectionObserver.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, useRef, MutableRefObject } from 'react'
2 |
3 | // Define the types for the hook options and return value
4 | interface IntersectionObserverOptions {
5 | root?: Element | null
6 | rootMargin?: string
7 | threshold?: number | number[]
8 | }
9 |
10 | interface IntersectionObserverHookReturn {
11 | ref: MutableRefObject
12 | isIntersecting: boolean
13 | stopObserving: () => void
14 | }
15 |
16 | // Define the hook with type safety
17 | const useIntersectionObserver = (
18 | options: IntersectionObserverOptions
19 | ): IntersectionObserverHookReturn => {
20 | const [isIntersecting, setIsIntersecting] = useState(false)
21 | const ref = useRef(null)
22 | const observerRef = useRef(null)
23 |
24 | useEffect(() => {
25 | const currentRef = ref.current // Copy ref.current to a local variable
26 | const observer = new IntersectionObserver(([entry]) => {
27 | setIsIntersecting(entry.isIntersecting)
28 | }, options)
29 |
30 | observerRef.current = observer
31 |
32 | if (currentRef) {
33 | observer.observe(currentRef)
34 | }
35 |
36 | return () => {
37 | if (currentRef) {
38 | observer.unobserve(currentRef)
39 | }
40 | }
41 | }, [options])
42 |
43 | const stopObserving = () => {
44 | if (observerRef.current && ref.current) {
45 | observerRef.current.unobserve(ref.current)
46 | }
47 | }
48 |
49 | return { ref, isIntersecting, stopObserving }
50 | }
51 |
52 | export default useIntersectionObserver
53 |
--------------------------------------------------------------------------------
/app/_hooks/useIsomorphicLayoutEffect.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useLayoutEffect } from 'react'
2 |
3 | const useIsomorphicLayoutEffect =
4 | typeof window !== 'undefined' ? useLayoutEffect : useEffect
5 |
6 | export default useIsomorphicLayoutEffect
7 |
--------------------------------------------------------------------------------
/app/_hooks/useLockedBody.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'
4 |
5 | type UseLockedBodyOutput = [boolean, (locked: boolean) => void]
6 |
7 | function useLockedBody(
8 | initialLocked = false,
9 | rootId = '__app'
10 | ): UseLockedBodyOutput {
11 | const [locked, setLocked] = useState(initialLocked)
12 |
13 | // Do the side effect before render
14 | useIsomorphicLayoutEffect(() => {
15 | if (!locked) {
16 | return
17 | }
18 |
19 | // Save initial body style
20 | const originalOverflow = document.body.style.overflow
21 | const originalPaddingRight = document.body.style.paddingRight
22 |
23 | // Lock body scroll
24 | document.body.style.overflow = 'hidden'
25 |
26 | // Get the scrollBar width
27 | const root = document.getElementById(rootId) // or root
28 | const scrollBarWidth = root ? root.offsetWidth - root.scrollWidth : 0
29 |
30 | // Avoid width reflow
31 | if (scrollBarWidth) {
32 | document.body.style.paddingRight = `${scrollBarWidth}px`
33 | }
34 |
35 | return () => {
36 | document.body.style.overflow = originalOverflow
37 |
38 | if (scrollBarWidth) {
39 | document.body.style.paddingRight = originalPaddingRight
40 | }
41 | }
42 | }, [locked])
43 |
44 | // Update state if initialValue changes
45 | useEffect(() => {
46 | if (locked !== initialLocked) {
47 | setLocked(initialLocked)
48 | }
49 | // eslint-disable-next-line react-hooks/exhaustive-deps
50 | }, [initialLocked])
51 |
52 | return [locked, setLocked]
53 | }
54 |
55 | export default useLockedBody
56 |
--------------------------------------------------------------------------------
/app/_hooks/usePendingJob.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { useStore } from 'statery'
3 | import { PendingImagesStore } from '../_stores/PendingImagesStore'
4 | import { deepEqual } from '../_utils/deepEqual'
5 | import { ArtBotHordeJob } from '../_data-models/ArtBotHordeJob'
6 |
7 | export const usePendingJob = (artbot_id: string) => {
8 | const { pendingImages } = useStore(PendingImagesStore)
9 | const [pendingJob, setPendingJob] = useState(
10 | {} as ArtBotHordeJob
11 | )
12 |
13 | useEffect(() => {
14 | const updatedJob =
15 | pendingImages.find((job) => job.artbot_id === artbot_id) ||
16 | ({} as ArtBotHordeJob)
17 |
18 | if (!deepEqual(updatedJob, pendingJob)) {
19 | setPendingJob(updatedJob)
20 | return
21 | }
22 | }, [artbot_id, pendingImages, pendingJob])
23 |
24 | return [pendingJob]
25 | }
26 |
--------------------------------------------------------------------------------
/app/_hooks/useResizeObserver.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, RefObject } from 'react'
2 |
3 | const useResizeObserver = (ref: RefObject) => {
4 | const [width, setWidth] = useState(0)
5 |
6 | useEffect(() => {
7 | const observedElement = ref.current
8 | const resizeObserver = new ResizeObserver((entries) => {
9 | if (entries[0]) {
10 | setWidth(entries[0].contentRect.width)
11 | }
12 | })
13 |
14 | if (observedElement) {
15 | resizeObserver.observe(observedElement)
16 | }
17 |
18 | return () => {
19 | if (observedElement) {
20 | resizeObserver.unobserve(observedElement)
21 | }
22 | }
23 | }, [ref])
24 |
25 | return width
26 | }
27 |
28 | export default useResizeObserver
29 |
--------------------------------------------------------------------------------
/app/_hooks/useUndoPrompt.tsx:
--------------------------------------------------------------------------------
1 | import { Dispatch, SetStateAction, useState } from 'react'
2 |
3 | export default function useUndoPrompt(): [
4 | string,
5 | Dispatch>,
6 | string,
7 | Dispatch>
8 | ] {
9 | const [undoPrompt, setUndoPrompt] = useState('')
10 | const [undoNegative, setUndoNegative] = useState('')
11 |
12 | return [undoPrompt, setUndoPrompt, undoNegative, setUndoNegative]
13 | }
14 |
--------------------------------------------------------------------------------
/app/_hooks/useWindowSize.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import { debounce } from '../_utils/debounce'
3 |
4 | export interface WindowSize {
5 | width: number | undefined
6 | height: number | undefined
7 | }
8 |
9 | export function useWindowSize() {
10 | // Initialize state with undefined width/height so server and client renders match
11 | const [windowSize, setWindowSize] = useState({
12 | width: undefined,
13 | height: undefined
14 | })
15 |
16 | useEffect(() => {
17 | if (typeof window === 'undefined') {
18 | return
19 | }
20 |
21 | // Handler to call on window resize
22 | const handleResize = () => {
23 | // Set window width/height to state
24 | setWindowSize({
25 | width: window.innerWidth,
26 | height: window.innerHeight
27 | })
28 | }
29 |
30 | // Debounced version of the handleResize function
31 | const debouncedHandleResize = debounce(handleResize, 200)
32 |
33 | // Add event listener with debounced handler
34 | window.addEventListener('resize', debouncedHandleResize)
35 |
36 | // Call handler right away so state gets updated with initial window size
37 | debouncedHandleResize()
38 |
39 | // Remove event listener on cleanup
40 | return () => window.removeEventListener('resize', debouncedHandleResize)
41 | }, []) // Empty array ensures that effect is only run on mount
42 |
43 | return windowSize
44 | }
45 |
--------------------------------------------------------------------------------
/app/_providers/ModalProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import NiceModal from '@ebay/nice-modal-react';
4 | import { ReactNode } from 'react';
5 | import Modal from '@/app/_components/Modal';
6 |
7 | /**
8 | * We need to duplicate registration of "Modal" for the "delete" key
9 | * (and others) because these modals can appear on top of other modals
10 | * that may be open, otherwise, if the delete modal is opened using the
11 | * "modal" key, other modals using the "modal" key will be closed.
12 | */
13 |
14 | NiceModal.register('delete', Modal); // Appears on top of other image modals
15 | NiceModal.register('embeddingDetails', Modal); // Appears on top of LoRA search modal
16 | NiceModal.register('hordePerfModal', Modal);
17 | NiceModal.register('modal', Modal);
18 | NiceModal.register('workerDetails', Modal);
19 | NiceModal.register('modifyWorker', Modal); // Appears on top of workerDetails modal, modifies things like name, description, etc.
20 |
21 | export default function ModalProvider({ children }: { children: ReactNode }) {
22 | return {children} ;
23 | }
24 |
--------------------------------------------------------------------------------
/app/_stores/AppStore.ts:
--------------------------------------------------------------------------------
1 | import { makeStore } from 'statery';
2 |
3 | interface AppStoreInterface {
4 | buildId: string;
5 | hordeOnline: boolean;
6 | online: boolean;
7 | }
8 |
9 | export const AppStore = makeStore({
10 | buildId: '',
11 | hordeOnline: true,
12 | online: true
13 | });
14 |
15 | export const setAppBuildId = (update: string) => {
16 | AppStore.set(() => ({ buildId: update }));
17 | };
18 |
19 | export const setAppOnlineStatus = (update: boolean) => {
20 | AppStore.set(() => ({ online: update }));
21 | };
22 |
23 | export const setHordeOnlineStatus = (update: boolean) => {
24 | AppStore.set(() => ({ hordeOnline: update }));
25 | };
26 |
--------------------------------------------------------------------------------
/app/_stores/CreateImageStore.ts:
--------------------------------------------------------------------------------
1 | import { makeStore } from 'statery'
2 |
3 | interface ImageStoreInterface {
4 | inputUpdated: number
5 | }
6 |
7 | export const CreateImageStore = makeStore({
8 | inputUpdated: Date.now()
9 | })
10 |
11 | /**
12 | * Update input timestamp
13 | * Used to force /create page to pull PromptInput changes from session store.
14 | * Useful when re-rolling or editing previous input.
15 | */
16 | export const updateInputTimstamp = () => {
17 | CreateImageStore.set(() => ({ inputUpdated: Date.now() }))
18 | }
19 |
--------------------------------------------------------------------------------
/app/_stores/GalleryStore.ts:
--------------------------------------------------------------------------------
1 | import { makeStore } from 'statery'
2 |
3 | interface GalleryStoreInterface {
4 | currentPage: number
5 | groupImages: boolean
6 | showFavorites: 'all' | 'favs' | 'non-favs'
7 | sortBy: 'asc' | 'desc'
8 | }
9 |
10 | export const GalleryStore = makeStore({
11 | currentPage: 0,
12 | groupImages: true,
13 | showFavorites: 'all',
14 | sortBy: 'desc'
15 | })
16 |
17 | export const setGalleryCurrentPage = (update: number) => {
18 | GalleryStore.set((s) => ({ ...s, currentPage: update }))
19 | }
20 |
21 | export const setGalleryGroupImages = (update: boolean) => {
22 | GalleryStore.set((s) => ({ ...s, groupImages: update }))
23 | }
24 |
25 | export const setGallerySortBy = (update: 'asc' | 'desc') => {
26 | GalleryStore.set((s) => ({ ...s, sortBy: update }))
27 | }
28 |
29 | export const setGalleryShowFavorites = (
30 | update: 'all' | 'favs' | 'non-favs'
31 | ) => {
32 | GalleryStore.set((s) => ({ ...s, showFavorites: update }))
33 | }
34 |
--------------------------------------------------------------------------------
/app/_stores/ImageStore.ts:
--------------------------------------------------------------------------------
1 | import { makeStore } from 'statery'
2 |
3 | interface ImageStoreInterface {
4 | fullscreenImageId: string | null
5 | }
6 |
7 | export const ImageStore = makeStore({
8 | fullscreenImageId: null
9 | })
10 |
11 | export const setFullscreenImageId = (image_id: string | null) => {
12 | ImageStore.set(() => ({ fullscreenImageId: image_id }))
13 | }
14 |
--------------------------------------------------------------------------------
/app/_stores/ModelStore.ts:
--------------------------------------------------------------------------------
1 | import { makeStore } from 'statery'
2 | import { AvailableImageModel, ImageModelDetails } from '../_types/HordeTypes'
3 |
4 | interface ModelStoreInterface {
5 | availableModels: AvailableImageModel[]
6 | modelDetails: {
7 | [key: string]: ImageModelDetails
8 | }
9 | }
10 |
11 | export const ModelStore = makeStore({
12 | availableModels: [],
13 | modelDetails: {}
14 | })
15 |
16 | export const setAvailableModels = (models: AvailableImageModel[]) => {
17 | ModelStore.set(() => ({ availableModels: models }))
18 | }
19 |
20 | export const setImageModels = (modelDetails: {
21 | [key: string]: ImageModelDetails
22 | }) => {
23 | ModelStore.set(() => ({ modelDetails }))
24 | }
25 |
--------------------------------------------------------------------------------
/app/_stores/UserStore.ts:
--------------------------------------------------------------------------------
1 | import { makeStore } from 'statery';
2 | import { HordeUser, WorkerMessage } from '../_types/HordeTypes';
3 |
4 | interface UserStoreInterface {
5 | forceAllowedWorkers: boolean;
6 | forceBlockedWorkers: boolean;
7 | forceSelectedWorker: boolean;
8 | hordeMessages: WorkerMessage[];
9 | userDetails: HordeUser;
10 | sharedKey: string;
11 | }
12 |
13 | export const UserStore = makeStore({
14 | forceAllowedWorkers: false,
15 | forceBlockedWorkers: false,
16 | forceSelectedWorker: false,
17 | hordeMessages: [],
18 | userDetails: {} as HordeUser,
19 | sharedKey: ''
20 | });
21 |
22 | export const setForceSelectedWorker = (val: boolean) => {
23 | UserStore.set(() => ({
24 | forceSelectedWorker: val
25 | }));
26 | };
27 |
28 | export const updateUseSharedKey = (key: string) => {
29 | UserStore.set(() => ({ sharedKey: key }));
30 | };
31 |
32 | export const updateUser = (user: HordeUser) => {
33 | UserStore.set(() => ({ userDetails: user }));
34 | };
35 |
36 | export const updateHordeMessages = (messages: WorkerMessage[]) => {
37 | UserStore.set((state) => ({
38 | hordeMessages: [
39 | ...state.hordeMessages,
40 | ...messages.filter(
41 | (message) =>
42 | !state.hordeMessages.some((existing) => existing.id === message.id)
43 | )
44 | ]
45 | }));
46 | };
47 |
48 | export const updateWorkerUsagePreference = ({
49 | forceAllowedWorkers,
50 | forceBlockedWorkers
51 | }: {
52 | forceAllowedWorkers: boolean;
53 | forceBlockedWorkers: boolean;
54 | }) => {
55 | UserStore.set(() => ({ forceAllowedWorkers, forceBlockedWorkers }));
56 | };
57 |
--------------------------------------------------------------------------------
/app/_types/CivitaiTypes.ts:
--------------------------------------------------------------------------------
1 | import { Embedding } from '../_data-models/Civitai'
2 |
3 | export interface CivitAiApiResponse {
4 | items: Embedding[]
5 | metadata: {
6 | nextCursor: string
7 | nextPage: string
8 | currentPage: number
9 | pageSize: number
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/app/_types/google-api.ts:
--------------------------------------------------------------------------------
1 | export interface DriveFileResource {
2 | name: string;
3 | parents?: string[];
4 | mimeType?: string;
5 | }
6 |
7 | export interface DriveFileMedia {
8 | mimeType?: string;
9 | body: Blob | string;
10 | }
11 |
12 | export interface DriveListParams {
13 | pageSize?: number;
14 | fields: string;
15 | q?: string;
16 | spaces?: string;
17 | }
18 |
19 | export interface GoogleAuthState {
20 | isSignedIn: boolean;
21 | user: { name: string; email: string } | null;
22 | gapiInited: boolean;
23 | gisInited: boolean;
24 | }
25 |
26 | export interface DriveFile {
27 | id: string;
28 | name: string;
29 | }
30 |
31 | export const GOOGLE_API_CONFIG = {
32 | API_KEY: (process.env.NODE_ENV === 'development' ? process.env.NEXT_PUBLIC_DEV_GOOGLE_CLOUD_KEY : 'AIzaSyD1eYeuYWH3MFmRKd7-oB8C_fozA2KWJwU') || '',
33 | CLIENT_ID: '773317214900-qo83bui0bdh2kdkhkn6iafq2uvb1dsdr.apps.googleusercontent.com',
34 | DISCOVERY_DOC: 'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest',
35 | SCOPES: 'https://www.googleapis.com/auth/drive.file'
36 | } as const;
--------------------------------------------------------------------------------
/app/_utils/arrayUtils.ts:
--------------------------------------------------------------------------------
1 | import { SavedEmbedding, SavedLora } from '../_data-models/Civitai'
2 |
3 | interface JsonData {
4 | [key: string]: string[]
5 | }
6 |
7 | export const mergeArrays = (jsonData: JsonData): string[] => {
8 | let mergedArray: string[] = []
9 |
10 | Object.keys(jsonData).forEach((key) => {
11 | mergedArray = mergedArray.concat(jsonData[key])
12 | })
13 |
14 | return mergedArray
15 | }
16 |
17 | function isSavedEmbedding(
18 | embedding: SavedEmbedding | SavedLora
19 | ): embedding is SavedEmbedding {
20 | return (
21 | (embedding as SavedEmbedding)._civitAiType !== undefined &&
22 | (embedding as SavedEmbedding)._civitAiType === 'TextualInversion'
23 | )
24 | }
25 |
26 | function isSavedLora(
27 | embedding: SavedEmbedding | SavedLora
28 | ): embedding is SavedLora {
29 | return (
30 | (embedding as SavedLora)._civitAiType !== undefined &&
31 | (embedding as SavedLora)._civitAiType === 'Lora'
32 | )
33 | }
34 |
35 | export const flattenKeywords = (
36 | jsonData: SavedEmbedding[] | SavedLora[] = []
37 | ): string[] => {
38 | return jsonData.reduce(
39 | (acc: string[], embedding: SavedEmbedding | SavedLora) => {
40 | if (!embedding || !embedding.modelVersions) return acc
41 |
42 | if (isSavedEmbedding(embedding) && embedding.inject_ti === 'none') {
43 | acc = acc.concat(embedding.tags)
44 | } else if (isSavedLora(embedding) && embedding.modelVersions.length > 0) {
45 | // Flatten and concatenate the trainedWords of the first model version to the accumulator
46 | acc = acc.concat(embedding.modelVersions[0].trainedWords)
47 | }
48 | return acc
49 | },
50 | []
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/app/_utils/browserUtils.ts:
--------------------------------------------------------------------------------
1 | // This is needed to handle instances where API_BASE_PATH is not set (or is ""),
2 | // which is casted to "undefined". We need to properly handle that here.
3 | export const appBasepath = () => {
4 | const path = process.env.NEXT_PUBLIC_API_BASE_PATH
5 |
6 | // People shouldn't add only a slash to the config basepath, but they probably will anyway.
7 | if (path === '/') {
8 | return ''
9 | }
10 |
11 | if (path) {
12 | return path
13 | }
14 |
15 | // Return EMPTY string, and not "/", otherwise BAD_THINGS_HAPPEN.
16 | return ''
17 | }
18 |
19 | export const isiOS = () => {
20 | return (
21 | [
22 | 'iPad Simulator',
23 | 'iPhone Simulator',
24 | 'iPod Simulator',
25 | 'iPad',
26 | 'iPhone',
27 | 'iPod'
28 | ].includes(navigator?.platform) ||
29 | // iPad on iOS 13 detection
30 | (navigator.userAgent.includes('Mac') && 'ontouchend' in document)
31 | )
32 | }
33 |
34 | export const isSafariBrowser = () => {
35 | const is_chrome = navigator.userAgent.indexOf('Chrome') > -1
36 | const is_safari = navigator.userAgent.indexOf('Safari') > -1
37 |
38 | if (is_safari) {
39 | if (is_chrome)
40 | // Chrome seems to have both Chrome and Safari userAgents
41 | return false
42 | else return true
43 | }
44 | return false
45 | }
46 |
--------------------------------------------------------------------------------
/app/_utils/debounce.test.ts:
--------------------------------------------------------------------------------
1 | import { debounce } from './debounce'
2 |
3 | describe('debounce', () => {
4 | beforeEach(() => {
5 | jest.useFakeTimers()
6 | })
7 |
8 | afterEach(() => {
9 | jest.useRealTimers()
10 | })
11 |
12 | it('should call the function after the specified wait time', () => {
13 | const func = jest.fn()
14 | const debouncedFunc = debounce(func, 100)
15 |
16 | debouncedFunc()
17 | expect(func).not.toHaveBeenCalled()
18 |
19 | jest.advanceTimersByTime(100)
20 | expect(func).toHaveBeenCalledTimes(1)
21 | })
22 |
23 | it('should call the function only once if called multiple times within the wait time', () => {
24 | const func = jest.fn()
25 | const debouncedFunc = debounce(func, 100)
26 |
27 | debouncedFunc()
28 | debouncedFunc()
29 | debouncedFunc()
30 | expect(func).not.toHaveBeenCalled()
31 |
32 | jest.advanceTimersByTime(100)
33 | expect(func).toHaveBeenCalledTimes(1)
34 | })
35 |
36 | it('should call the function with the latest arguments', () => {
37 | const func = jest.fn()
38 | const debouncedFunc = debounce(func, 100)
39 |
40 | debouncedFunc('first call')
41 | debouncedFunc('second call')
42 | debouncedFunc('third call')
43 | expect(func).not.toHaveBeenCalled()
44 |
45 | jest.advanceTimersByTime(100)
46 | expect(func).toHaveBeenCalledTimes(1)
47 | expect(func).toHaveBeenCalledWith('third call')
48 | })
49 |
50 | it('should restart the wait time if called again within the wait time', () => {
51 | const func = jest.fn()
52 | const debouncedFunc = debounce(func, 100)
53 |
54 | debouncedFunc()
55 | jest.advanceTimersByTime(50)
56 | debouncedFunc()
57 | jest.advanceTimersByTime(50)
58 | expect(func).not.toHaveBeenCalled()
59 |
60 | jest.advanceTimersByTime(50)
61 | expect(func).toHaveBeenCalledTimes(1)
62 | })
63 | })
64 |
--------------------------------------------------------------------------------
/app/_utils/debounce.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
2 | export interface DebouncedFunction any> {
3 | (...args: Parameters): void
4 | cancel: () => void
5 | }
6 |
7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
8 | export function debounce any>(
9 | func: F,
10 | waitFor: number
11 | ): DebouncedFunction {
12 | let timeout: ReturnType | null = null
13 |
14 | const debounced = (...args: Parameters) => {
15 | if (timeout !== null) {
16 | clearTimeout(timeout)
17 | }
18 | timeout = setTimeout(() => func(...args), waitFor)
19 | }
20 |
21 | debounced.cancel = () => {
22 | if (timeout !== null) {
23 | clearTimeout(timeout)
24 | }
25 | }
26 |
27 | return debounced
28 | }
29 |
--------------------------------------------------------------------------------
/app/_utils/deepEqual.ts:
--------------------------------------------------------------------------------
1 | export const deepEqual = (obj1: unknown = {}, obj2: unknown = {}): boolean => {
2 | const isObj1Array = Array.isArray(obj1)
3 | const isObj2Array = Array.isArray(obj2)
4 |
5 | if (isObj1Array !== isObj2Array) {
6 | return false
7 | }
8 |
9 | if (typeof obj1 !== typeof obj2) {
10 | return false
11 | }
12 |
13 | if (obj1 === obj2) {
14 | return true
15 | }
16 |
17 | if (
18 | obj1 == null ||
19 | typeof obj1 != 'object' ||
20 | obj2 == null ||
21 | typeof obj2 != 'object'
22 | ) {
23 | return false
24 | }
25 |
26 | const keysObj1 = Object.keys(obj1)
27 | const keysObj2 = Object.keys(obj2)
28 |
29 | if (keysObj1.length != keysObj2.length) {
30 | return false
31 | }
32 |
33 | for (const key of keysObj1) {
34 | // @ts-expect-error we don't know type
35 | if (!keysObj2.includes(key) || !deepEqual(obj1[key], obj2[key])) {
36 | return false
37 | }
38 | }
39 |
40 | return true
41 | }
42 |
--------------------------------------------------------------------------------
/app/_utils/fileUtils.ts:
--------------------------------------------------------------------------------
1 | import { HordeApiParams, ImageParamsForHordeApi } from '../_data-models/ImageParamsForHordeApi';
2 | import type PromptInput from '../_data-models/PromptInput';
3 | import { ImageDetails } from '../_components/ImageView/ImageViewProvider';
4 | import { GenMetadata } from '../_types/HordeTypes';
5 |
6 | interface AdditionalDetails extends HordeApiParams {
7 | gen_metadata: GenMetadata[];
8 | modelDetails: {
9 | baseline: string
10 | version: string
11 | };
12 | }
13 |
14 | /**
15 | * Creates a JSON Blob from the given ImageRequest.
16 | */
17 | export const createJsonAttachmentFromImageDetails = async (imageId: string, imageData: ImageDetails): Promise => {
18 | const { imageFiles } = imageData;
19 | const imageFileDetails = imageFiles.filter(
20 | (file) => file.image_id === imageId
21 | );
22 |
23 | const { imageRequest } = imageData;
24 | const rawPayload = await ImageParamsForHordeApi.build(imageRequest)
25 | const { apiParams, imageDetails }: { apiParams: HordeApiParams, imageDetails: PromptInput } = rawPayload;
26 |
27 | const additionalDetails: AdditionalDetails = {
28 | ...apiParams,
29 | modelDetails: imageDetails.modelDetails,
30 | gen_metadata: imageFileDetails[0].gen_metadata || []
31 | };
32 |
33 | additionalDetails.params.seed = imageFileDetails[0].seed;
34 |
35 | const prettyJson = JSON.stringify(additionalDetails, null, 2);
36 | return new Blob([prettyJson], { type: 'application/json' });
37 | };
--------------------------------------------------------------------------------
/app/_utils/hordeUtils.ts:
--------------------------------------------------------------------------------
1 | import { SavedEmbedding } from '../_data-models/Civitai'
2 | import { JobStatus } from '../_types/ArtbotTypes'
3 | import { HordeTi } from '../_types/HordeTypes'
4 |
5 | export const castTiInject = (tis: SavedEmbedding[]): HordeTi[] => {
6 | let updatedTis: HordeTi[] = []
7 | if (tis && Array.isArray(tis) && tis.length > 0) {
8 | updatedTis = tis.map((ti) => {
9 | const obj: HordeTi = {
10 | name: String(ti.id),
11 | strength: 0
12 | }
13 |
14 | if (ti.inject_ti === 'prompt' || ti.inject_ti === 'negprompt') {
15 | obj.inject_ti = ti.inject_ti
16 | }
17 |
18 | if (ti.strength) {
19 | obj.strength = ti.strength
20 | }
21 |
22 | return obj
23 | })
24 | }
25 |
26 | return updatedTis
27 | }
28 |
29 | export const formatJobStatus = (status: JobStatus) => {
30 | switch (status) {
31 | case 'waiting':
32 | return 'Waiting'
33 | case 'queued':
34 | return 'Queued'
35 | case 'requested':
36 | return 'Requested'
37 | case 'processing':
38 | return 'Processing'
39 | case 'done':
40 | return 'Done'
41 | case 'error':
42 | return 'Error'
43 | default:
44 | return status
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/app/_utils/inputUtils.ts:
--------------------------------------------------------------------------------
1 | import cloneDeep from 'clone-deep'
2 | import { ImageRequest } from '../_types/ArtbotTypes'
3 | import PromptInput from '../_data-models/PromptInput'
4 |
5 | interface CleanInputOptions {
6 | artbot_id?: string
7 | keepSeed?: boolean
8 | numImages?: number
9 | }
10 |
11 | export const cleanImageRequestForReuse = (
12 | input: ImageRequest,
13 | options: CleanInputOptions = {}
14 | ): PromptInput => {
15 | const { artbot_id = '', keepSeed = true, numImages = 0 } = options
16 |
17 | const updatedInput = cloneDeep(input)
18 |
19 | // @ts-expect-error ID is added when ImageRequest is added to Dexie
20 | delete updatedInput.id
21 |
22 | if (artbot_id) {
23 | updatedInput.artbot_id = artbot_id
24 | } else {
25 | // @ts-expect-error New ArtBot ID will be added when creating image request.
26 | delete updatedInput.artbot_id
27 | }
28 |
29 | if (!keepSeed) {
30 | updatedInput.seed = ''
31 | }
32 |
33 | if (numImages > 0) {
34 | updatedInput.numImages = numImages
35 | }
36 |
37 | return updatedInput
38 | }
39 |
--------------------------------------------------------------------------------
/app/_utils/sleep.ts:
--------------------------------------------------------------------------------
1 | export const sleep = (ms = 1000) => {
2 | return new Promise((resolve) => setTimeout(resolve, ms))
3 | }
4 |
--------------------------------------------------------------------------------
/app/_utils/stringUtils.ts:
--------------------------------------------------------------------------------
1 | export function isUuid(str: string) {
2 | const uuidRegex =
3 | /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
4 | return uuidRegex.test(str)
5 | }
6 |
7 | interface PromptInput {
8 | positive: string
9 | negative?: string
10 | stylePresetPrompt: string
11 | }
12 |
13 | export const formatStylePresetPrompt = (input: PromptInput): string => {
14 | const { positive, negative, stylePresetPrompt } = input
15 |
16 | let finalPrompt = stylePresetPrompt.replace('{p}', positive)
17 |
18 | if (negative && negative.trim() !== '') {
19 | if (
20 | !stylePresetPrompt.includes('###') &&
21 | stylePresetPrompt.includes('{np}')
22 | ) {
23 | finalPrompt = finalPrompt.replace('{np}', `### ${negative}`)
24 | } else {
25 | finalPrompt = finalPrompt.replace('{np}', negative)
26 | }
27 | } else {
28 | finalPrompt = finalPrompt.replace('{np}', '')
29 | }
30 |
31 | return finalPrompt
32 | }
33 |
--------------------------------------------------------------------------------
/app/_utils/throttle.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
2 | export default function throttle Promise>(
3 | func: T,
4 | limit: number
5 | ): (...args: Parameters) => Promise> {
6 | let inThrottle = false
7 | let lastPromise: Promise> | null = null
8 |
9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
10 | return function (this: any, ...args: Parameters): Promise> {
11 | if (!inThrottle) {
12 | inThrottle = true
13 | lastPromise = func.apply(this, args)
14 | setTimeout(() => {
15 | inThrottle = false
16 | }, limit)
17 | return lastPromise
18 | } else {
19 | return lastPromise || Promise.reject('Function throttled')
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/_utils/urlUtils.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import pako from 'pako'
3 |
4 | export const getBaseUrl = (): string => {
5 | const { protocol, host } = window.location
6 | const baseUrl = `${protocol}//${host}`
7 |
8 | // Check if the port is provided and add it to the URL
9 | // if (port) {
10 | // baseUrl += `:${port}`
11 | // }
12 |
13 | return baseUrl
14 | }
15 |
16 | // Helper function to convert Uint8Array to a base64 string
17 | export const uint8ArrayToBase64 = (array: Uint8Array): string => {
18 | let binary = ''
19 | const len = array.byteLength
20 | for (let i = 0; i < len; i++) {
21 | binary += String.fromCharCode(array[i])
22 | }
23 | return btoa(binary)
24 | }
25 |
26 | // Helper function to convert a base64 string to Uint8Array
27 | export const base64ToUint8Array = (base64: string): Uint8Array => {
28 | const binary = atob(base64)
29 | const len = binary.length
30 | const array = new Uint8Array(len)
31 | for (let i = 0; i < len; i++) {
32 | array[i] = binary.charCodeAt(i)
33 | }
34 | return array
35 | }
36 |
37 | // Function to compress and encode a JSON object
38 | export const compressAndEncode = (jsonObject: Record): string => {
39 | const jsonString = JSON.stringify(jsonObject)
40 | const compressed = pako.deflate(jsonString)
41 | return uint8ArrayToBase64(compressed)
42 | }
43 |
44 | // Function to decode and decompress a base64 string
45 | export const decodeAndDecompress = (
46 | encodedData: string
47 | ): Record => {
48 | const compressed = base64ToUint8Array(encodedData)
49 | const jsonString = pako.inflate(compressed, { to: 'string' })
50 | return JSON.parse(jsonString)
51 | }
52 |
53 | // Function to get data from the URL hash
54 | export const getHashData = (hash: string): string | null => {
55 | const params = new URLSearchParams(hash.slice(1)) // Remove the leading '#'
56 | return params.get('share')
57 | }
58 |
--------------------------------------------------------------------------------
/app/api/debug/save-response/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server'
2 | import fs from 'fs/promises'
3 | import path from 'path'
4 | import os from 'os'
5 |
6 | export async function POST(request: NextRequest) {
7 | const { id, data, route } = await request.json()
8 | const now = new Date()
9 | const timestamp = now.toISOString().replace(/[:T]/g, '-').split('.')[0]
10 | const prettyDate = now.toLocaleString('en-US', {
11 | year: 'numeric',
12 | month: '2-digit',
13 | day: '2-digit',
14 | hour: '2-digit',
15 | minute: '2-digit',
16 | second: '2-digit',
17 | hour12: true
18 | })
19 | const filename = `${timestamp}.txt`
20 |
21 | // Use the home directory and append the desired path, including the id
22 | const baseLogDir = path.join(os.homedir(), 'projects', 'logs')
23 | const idLogDir = path.join(baseLogDir, id)
24 | const filePath = path.join(idLogDir, filename)
25 |
26 | const fileContent = `${prettyDate}
27 | ${route}
28 | ${id}
29 |
30 | ${JSON.stringify(data, null, 2)}
31 | `
32 |
33 | try {
34 | // Ensure the base directory and id-specific directory exist
35 | await fs.mkdir(baseLogDir, { recursive: true })
36 | await fs.mkdir(idLogDir, { recursive: true })
37 |
38 | // Write the file
39 | await fs.writeFile(filePath, fileContent)
40 |
41 | return NextResponse.json(
42 | { message: 'Response saved successfully' },
43 | { status: 200 }
44 | )
45 | } catch (error) {
46 | console.error('Error saving response:', error)
47 | return NextResponse.json(
48 | { error: 'Failed to save response' },
49 | { status: 500 }
50 | )
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/app/api/heartbeat/route.ts:
--------------------------------------------------------------------------------
1 | export async function GET() {
2 | let buildId = '42'
3 |
4 | try {
5 | const buildIdJson = await import('../../../buildId.json')
6 | buildId = buildIdJson.buildId || buildId
7 | } catch (error) {
8 | // We'll use the default fallback value
9 | }
10 |
11 | return Response.json({
12 | success: true,
13 | buildId: buildId
14 | })
15 | }
16 |
--------------------------------------------------------------------------------
/app/api/status/counter/images/route.ts:
--------------------------------------------------------------------------------
1 | const statusApi = process.env.ARTBOT_STATUS_API;
2 |
3 | export async function GET() {
4 | try {
5 | const response = await fetch(`${statusApi}/images/total`, {
6 | method: 'GET',
7 | next: { revalidate: 2 },
8 | headers: {
9 | 'Content-Type': 'application/json'
10 | }
11 | });
12 |
13 | const data = await response.json();
14 | return Response.json(data);
15 | } catch (error) {
16 | console.error('Error fetching images total:', error);
17 | return Response.json(
18 | { error: 'Failed to fetch images total' },
19 | { status: 500 }
20 | );
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/api/status/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from 'next/server';
2 | const statusApi = process.env.ARTBOT_STATUS_API;
3 |
4 | export async function POST(request: NextRequest) {
5 | const { type } = await request.json();
6 |
7 | if (type === 'image_done' && statusApi) {
8 | fetch(`${statusApi}/status`, {
9 | method: 'POST',
10 | headers: {
11 | 'Content-Type': 'application/json'
12 | },
13 | body: JSON.stringify({
14 | type: 'image',
15 | service: 'ArtBot_v2'
16 | })
17 |
18 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
19 | }).catch((_err) => {
20 | // Do nothing
21 | });
22 | }
23 |
24 | return Response.json({
25 | success: true
26 | });
27 | }
28 |
--------------------------------------------------------------------------------
/app/api/styles/route.ts:
--------------------------------------------------------------------------------
1 | import styleTags from './styleTags.json'
2 |
3 | export async function GET() {
4 | return Response.json({ data: styleTags })
5 | }
6 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --background-color: rgb(242, 242, 242);
7 | --footer-background: #919191;
8 | --footer-padding: 72px;
9 | --foreground-color: black;
10 | --input-background-color: rgb(249, 250, 251);
11 | --input-text-color: rgb(17, 24, 39);
12 | --link-text: #17cfbb;
13 | --link-active: #5eedde;
14 | --primary-color: rgb(20, 184, 166);
15 | }
16 |
17 | @media (min-width: 640px) {
18 | :root {
19 | --footer-padding: 8px;
20 | }
21 | }
22 |
23 | @media (prefers-color-scheme: dark) {
24 | :root {
25 | --background-color: #242424;
26 | --footer-background: #383838;
27 | --input-background-color: rgb(55, 65, 81);
28 | --input-text-color: black;
29 | --foreground-color: white;
30 | }
31 | }
32 |
33 | body {
34 | color: var(--foreground-color);
35 | background-color: var(--background-color);
36 | }
37 |
38 | html {
39 | scroll-padding-top: 58px;
40 | }
41 |
42 | /* lol. need this z-index so popover displays over existing modal */
43 | #headlessui-portal-root {
44 | z-index: 9999;
45 | }
46 |
47 | @layer components {
48 | .col {
49 | @apply flex flex-col gap-2
50 | }
51 |
52 | .row {
53 | @apply flex flex-row gap-2 items-center
54 | }
55 | }
56 |
57 | @layer utilities {
58 | .bg-input {
59 | background-color: var(--input-background-color);
60 | }
61 |
62 | .text-input-color {
63 | color: var(--input-text-color);
64 | }
65 |
66 | .primary-color {
67 | color: var(--primary-color);
68 | }
69 |
70 | .text-balance {
71 | text-wrap: balance;
72 | }
73 | }
--------------------------------------------------------------------------------
/app/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ArtBot",
3 | "short_name": "ArtBot",
4 | "icons": [
5 | {
6 | "src": "/icons/192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/icons/512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#000000",
17 | "background_color": "#000000",
18 | "start_url": "/",
19 | "display": "standalone",
20 | "orientation": "portrait"
21 | }
22 |
--------------------------------------------------------------------------------
/app/sw.ts:
--------------------------------------------------------------------------------
1 | import { defaultCache } from '@serwist/next/worker'
2 | import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'
3 | import { NetworkOnly, Serwist } from 'serwist'
4 |
5 | // This declares the value of `injectionPoint` to TypeScript.
6 | // `injectionPoint` is the string that will be replaced by the
7 | // actual precache manifest. By default, this string is set to
8 | // `"self.__SW_MANIFEST"`.
9 | declare global {
10 | interface WorkerGlobalScope extends SerwistGlobalConfig {
11 | __SW_MANIFEST: (PrecacheEntry | string)[] | undefined
12 | }
13 | }
14 |
15 | declare const self: ServiceWorkerGlobalScope
16 |
17 | const serwist = new Serwist({
18 | precacheEntries: self.__SW_MANIFEST,
19 | skipWaiting: true,
20 | clientsClaim: true,
21 | navigationPreload: true,
22 | runtimeCaching:
23 | process.env.NODE_ENV === 'development'
24 | ? undefined
25 | : [
26 | {
27 | matcher: ({ url }) => url.pathname.startsWith('/api/heartbeat'),
28 | handler: new NetworkOnly()
29 | },
30 | ...defaultCache
31 | ]
32 | })
33 |
34 | serwist.addEventListeners()
35 |
--------------------------------------------------------------------------------
/buildId.json:
--------------------------------------------------------------------------------
1 | {
2 | "buildId": "20250527_06.02.42"
3 | }
--------------------------------------------------------------------------------
/ecosystem.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Configuration file for use with PM2 in prod environment.
3 | */
4 |
5 | module.exports = {
6 | apps: [
7 | {
8 | name: "artbot",
9 | script: "node",
10 | args: "server.js",
11 | instances: 1,
12 | autorestart: true,
13 | env: {
14 | NODE_ENV: 'production',
15 | PORT: 3001
16 | }
17 | },
18 | {
19 | name: "artbot-staging",
20 | script: "node",
21 | args: "server.js",
22 | instances: 1,
23 | autorestart: true,
24 | env: {
25 | NODE_ENV: 'production',
26 | PORT: 3003
27 | }
28 | },
29 | ],
30 | };
--------------------------------------------------------------------------------
/generateBuildId.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-var-requires
2 | const fs = require('fs');
3 |
4 | function generateBuildId() {
5 | const now = new Date();
6 | const year = now.getUTCFullYear();
7 | const month = String(now.getUTCMonth() + 1).padStart(2, '0');
8 | const day = String(now.getUTCDate()).padStart(2, '0');
9 | const hours = String(now.getUTCHours()).padStart(2, '0');
10 | const minutes = String(now.getUTCMinutes()).padStart(2, '0');
11 | const seconds = String(now.getUTCSeconds()).padStart(2, '0');
12 |
13 | return `${year}${month}${day}_${hours}.${minutes}.${seconds}`;
14 | }
15 |
16 | const buildId = generateBuildId();
17 | const buildIdJson = JSON.stringify({ buildId }, null, 2);
18 |
19 | fs.writeFileSync('buildId.json', buildIdJson);
20 |
21 | console.log(`Build ID generated: ${buildId}`);
22 |
--------------------------------------------------------------------------------
/global.d.ts:
--------------------------------------------------------------------------------
1 | export { };
2 |
3 |
4 | declare global {
5 | declare module 'dirty-json'
6 |
7 | interface Blob {
8 | toPNG(callback?: () => void): Promise
9 | toWebP(callback?: () => void): Promise
10 | toJPEG(callback?: () => void): Promise
11 | addOrUpdateExifData(userComment: string): Promise
12 | }
13 |
14 | interface Window {
15 | gapi: {
16 | load: (api: string, callback: () => void) => void;
17 | client: {
18 | init: (config: {
19 | apiKey: string;
20 | discoveryDocs: string[];
21 | }) => Promise;
22 | getToken: () => { access_token: string, expires_in: number } | null;
23 | setToken: (token: { access_token: string }) => void;
24 | drive: {
25 | files: {
26 | create: (params: {
27 | resource: DriveFileResource;
28 | media?: DriveFileMedia;
29 | fields: string;
30 | uploadType?: string;
31 | }) => Promise<{ result: { id: string; name: string } }>;
32 | list: (params: DriveListParams) => Promise<{
33 | result: {
34 | files: Array<{ id: string; name: string }>;
35 | };
36 | }>;
37 | };
38 | };
39 | };
40 | };
41 | google: {
42 | accounts: {
43 | oauth2: {
44 | initTokenClient: (config: {
45 | client_id: string;
46 | scope: string;
47 | callback: (response: {
48 | expires_in?: number;
49 | access_token?: string;
50 | error?: string;
51 | }) => void;
52 | }) => {
53 | requestAccessToken: (params?: { prompt?: string }) => void;
54 | };
55 | revoke: (token: string) => void;
56 | };
57 | };
58 | };
59 | }
60 | }
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | process.noDeprecation = true;
2 | module.exports = {
3 | roots: [''],
4 | testEnvironment: 'jest-environment-jsdom',
5 | testRegex: '(/__tests__/.*|(\\.|/)(test))\\.[jt]sx?$',
6 | moduleFileExtensions: ['ts', 'tsx', 'js', 'json', 'jsx'],
7 | moduleNameMapper: {
8 | '^@/(.*)$': '/$1',
9 | '\\.(css|scss|sass)$': 'identity-obj-proxy'
10 | },
11 | testPathIgnorePatterns: [
12 | '/.next/',
13 | '[/\\\\](node_modules|.next)[/\\\\]',
14 | '/.jest/test-utils.tsx',
15 | '/__mocks__/*'
16 | ],
17 | transform: {
18 | '^.+\\.[jt]sx?$': [
19 | 'ts-jest',
20 | {
21 | isolatedModules: true,
22 | tsconfig: {
23 | jsx: 'react-jsx'
24 | }
25 | }
26 | ]
27 | },
28 | transformIgnorePatterns: ['/node_modules/', '^.+\\.module\\.(css)$']
29 | };
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | import withSerwistInit from '@serwist/next';
3 | import withBundleAnalyzer from '@next/bundle-analyzer';
4 |
5 | const BASE_PATH = process.env.BASE_PATH || ''; // Should be '' or '/artbot'
6 | const DEXIE_DB = process.env.DEXIE_DB || 'ArtBot_beta_v2';
7 | const HORDE_API_HOST = process.env.HORDE_API_HOST || 'https://aihorde.net';
8 |
9 | const withSerwist = withSerwistInit({
10 | cacheOnNavigation: true,
11 | reloadOnOnline: true,
12 | swSrc: 'app/sw.ts',
13 | swDest: 'public/sw.js',
14 | swUrl: `./sw.js`
15 | });
16 |
17 | const nextConfig = {
18 | basePath: BASE_PATH,
19 | env: {
20 | NEXT_PUBLIC_API_BASE_PATH: BASE_PATH,
21 | NEXT_PUBLIC_DEV_GOOGLE_CLOUD_KEY: process.env.GOOGLE_CLOUD_KEY || '',
22 | NEXT_HORDE_API_HOST: HORDE_API_HOST,
23 | NEXT_PUBLIC_DEXIE_DB: DEXIE_DB,
24 | NEXT_PUBLIC_SAVE_DEBUG_LOGS: 'false', // "true" or "false"
25 | NEXT_TELEMETRY_DISABLED: '1', // disable Vercel / NextJS telemetry
26 | ARTBOT_STATUS_API: process.env.ARTBOT_STATUS_API
27 | },
28 | output: 'standalone'
29 | };
30 |
31 | const analyzeBundleConfig = withBundleAnalyzer({
32 | enabled: process.env.ANALYZE === 'true'
33 | });
34 |
35 | export default analyzeBundleConfig(withSerwist(nextConfig));
36 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/public/artbot-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/artbot-logo.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/favicon.ico
--------------------------------------------------------------------------------
/public/front-page/artbot_poster.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/front-page/artbot_poster.png
--------------------------------------------------------------------------------
/public/front-page/astronaut.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/front-page/astronaut.png
--------------------------------------------------------------------------------
/public/front-page/brisket.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/front-page/brisket.jpg
--------------------------------------------------------------------------------
/public/front-page/chalet.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/front-page/chalet.png
--------------------------------------------------------------------------------
/public/front-page/chipmunk.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/front-page/chipmunk.png
--------------------------------------------------------------------------------
/public/front-page/himalays.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/front-page/himalays.png
--------------------------------------------------------------------------------
/public/front-page/industrial-lines.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/front-page/industrial-lines.png
--------------------------------------------------------------------------------
/public/front-page/mech_brain.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/front-page/mech_brain.png
--------------------------------------------------------------------------------
/public/front-page/penguin_surfing.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/front-page/penguin_surfing.png
--------------------------------------------------------------------------------
/public/front-page/raven.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/front-page/raven.png
--------------------------------------------------------------------------------
/public/front-page/refined-penguin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/front-page/refined-penguin.png
--------------------------------------------------------------------------------
/public/front-page/sf.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/front-page/sf.png
--------------------------------------------------------------------------------
/public/front-page/steampunk-pc.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/front-page/steampunk-pc.png
--------------------------------------------------------------------------------
/public/front-page/super-penguin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/front-page/super-penguin.png
--------------------------------------------------------------------------------
/public/front-page/wasteland-poster.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/front-page/wasteland-poster.png
--------------------------------------------------------------------------------
/public/icons/100.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/100.png
--------------------------------------------------------------------------------
/public/icons/1024.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/1024.png
--------------------------------------------------------------------------------
/public/icons/114.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/114.png
--------------------------------------------------------------------------------
/public/icons/120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/120.png
--------------------------------------------------------------------------------
/public/icons/128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/128.png
--------------------------------------------------------------------------------
/public/icons/144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/144.png
--------------------------------------------------------------------------------
/public/icons/152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/152.png
--------------------------------------------------------------------------------
/public/icons/16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/16.png
--------------------------------------------------------------------------------
/public/icons/167.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/167.png
--------------------------------------------------------------------------------
/public/icons/180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/180.png
--------------------------------------------------------------------------------
/public/icons/192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/192.png
--------------------------------------------------------------------------------
/public/icons/20.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/20.png
--------------------------------------------------------------------------------
/public/icons/256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/256.png
--------------------------------------------------------------------------------
/public/icons/29.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/29.png
--------------------------------------------------------------------------------
/public/icons/32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/32.png
--------------------------------------------------------------------------------
/public/icons/40.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/40.png
--------------------------------------------------------------------------------
/public/icons/50.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/50.png
--------------------------------------------------------------------------------
/public/icons/512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/512.png
--------------------------------------------------------------------------------
/public/icons/57.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/57.png
--------------------------------------------------------------------------------
/public/icons/58.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/58.png
--------------------------------------------------------------------------------
/public/icons/60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/60.png
--------------------------------------------------------------------------------
/public/icons/64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/64.png
--------------------------------------------------------------------------------
/public/icons/72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/72.png
--------------------------------------------------------------------------------
/public/icons/76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/76.png
--------------------------------------------------------------------------------
/public/icons/80.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/80.png
--------------------------------------------------------------------------------
/public/icons/87.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/icons/87.png
--------------------------------------------------------------------------------
/public/not-found.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/not-found.png
--------------------------------------------------------------------------------
/public/painting_bot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/painting_bot.png
--------------------------------------------------------------------------------
/public/random_noise.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/random_noise.jpg
--------------------------------------------------------------------------------
/public/tile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Haidra-Org/artbot/e64252f008fe23d57636bd34bc3ff844ded5e449/public/tile.png
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss'
2 |
3 | const config: Config = {
4 | content: [
5 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
6 | './components/**/*.{js,ts,jsx,tsx,mdx}',
7 | './app/**/*.{js,ts,jsx,tsx,mdx}'
8 | ],
9 | theme: {
10 | extend: {
11 | backgroundImage: {
12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
13 | 'gradient-conic':
14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))'
15 | },
16 | screens: {
17 | '3xl': '1792px',
18 | '4xl': '2049px',
19 | '5xl': '2305px'
20 | }
21 | }
22 | },
23 | plugins: []
24 | }
25 | export default config
26 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext", "webworker"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "noUnusedLocals": true,
9 | "noUnusedParameters": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "incremental": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | },
24 | "resolveJsonModule": true,
25 | "target": "es2015",
26 | "types": [
27 | "jest",
28 | // This allows Serwist to type `window.serwist`.
29 | "@serwist/next/typings"
30 | ]
31 | },
32 | "include": [
33 | "ecosystem.config.js",
34 | "next.config.mjs",
35 | "next-env.d.ts",
36 | "**/*.ts",
37 | "**/*.tsx",
38 | ".next/types/**/*.ts",
39 | "jest.config.js",
40 | "generateBuildId.js",
41 | "global.d.ts"
42 | ],
43 | "exclude": ["node_modules", "public/sw.js"]
44 | }
45 |
--------------------------------------------------------------------------------