├── .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 | painting of a confused robot 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 |
26 |
27 | {isSticky && } 28 |
29 |
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 |
36 |
37 | 38 | 39 |
40 |
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 | 34 | ) => 38 | setInputKeyName(event.target.value) 39 | } 40 | /> 41 |
42 |
43 | 44 | ) => 48 | setInputKeyKudos(event.target.value) 49 | } 50 | /> 51 |
52 |
53 | {/* */} 56 | 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 | 21 | setInputName(e.target.value)} 25 | /> 26 |
27 |
28 | 29 | setInputUrl(e.target.value)} 33 | /> 34 |
35 | 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 | 25 | ) : ( 26 |
27 | 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 | painting of a confused robot 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 | 34 | 43 | 52 | 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 | 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 | setInput({ seed: e.target.value })} 21 | // onKeyDown={handleKeyDown} 22 | value={input.seed} 23 | /> 24 |
25 | 36 | 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 |
28 | 29 |
30 |
31 | 32 | 33 |
34 |
35 | 36 | 37 |
38 |
39 | 40 | 41 | 42 |
43 | 44 | 45 |
46 | 47 |
48 | 49 |
50 | 51 |
52 |
53 | 54 |
55 |
56 | 57 |
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 |
45 | 46 | 47 |
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 | Carousel Slide 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 | 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 | 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 | 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 {alt}; 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 |
28 |
29 | 30 | 34 |
35 |
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 | 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 | 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 | 52 | 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 | 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 | 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 | --------------------------------------------------------------------------------