jbilcke-hf HF staff commited on
Commit
1c1e6e9
β€’
1 Parent(s): f6f68f6

slight UI redesign

Browse files
.env CHANGED
@@ -82,7 +82,7 @@ LLM_OPENAI_API_MODEL="gpt-4"
82
  LLM_HF_INFERENCE_ENDPOINT_URL=""
83
 
84
  # If you decided to use a Hugging Face Inference API model for the LLM engine
85
- # LLM_HF_INFERENCE_API_MODEL="meta-llama/Llama-2-70b-chat-hf"
86
  LLM_HF_INFERENCE_API_MODEL="HuggingFaceH4/zephyr-7b-beta"
87
 
88
  # ----------- COMMUNITY SHARING (OPTIONAL) -----------
 
82
  LLM_HF_INFERENCE_ENDPOINT_URL=""
83
 
84
  # If you decided to use a Hugging Face Inference API model for the LLM engine
85
+ # LLM_HF_INFERENCE_API_MODEL="HuggingFaceH4/zephyr-7b-beta"
86
  LLM_HF_INFERENCE_API_MODEL="HuggingFaceH4/zephyr-7b-beta"
87
 
88
  # ----------- COMMUNITY SHARING (OPTIONAL) -----------
README.md CHANGED
@@ -70,13 +70,13 @@ To customise a variable locally, you should create a `.env.local`
70
 
71
  ## The LLM API (Large Language Model)
72
 
73
- Currently the AI Comic Factory uses [Llama-2 70b](https://huggingface.co/blog/llama2) through an [Inference Endpoint](https://huggingface.co/docs/inference-endpoints/index).
74
 
75
  You have three options:
76
 
77
  ### Option 1: Use an Inference API model
78
 
79
- This is a new option added recently, where you can use one of the models from the Hugging Face Hub. By default we suggest to use CodeLlama 34b as it will provide better results than the 7b model.
80
 
81
  To activate it, create a `.env.local` configuration file:
82
 
@@ -85,10 +85,10 @@ LLM_ENGINE="INFERENCE_API"
85
 
86
  HF_API_TOKEN="Your Hugging Face token"
87
 
88
- # codellama/CodeLlama-7b-hf" is used by default, but you can change this
89
  # note: You should use a model able to generate JSON responses,
90
  # so it is storngly suggested to use at least the 34b model
91
- HF_INFERENCE_API_MODEL="codellama/CodeLlama-7b-hf"
92
  ```
93
 
94
  ### Option 2: Use an Inference Endpoint URL
 
70
 
71
  ## The LLM API (Large Language Model)
72
 
73
+ Currently the AI Comic Factory uses [zephyr-7b-beta](https://huggingface.co/HuggingFaceH4/zephyr-7b-beta) through an [Inference Endpoint](https://huggingface.co/docs/inference-endpoints/index).
74
 
75
  You have three options:
76
 
77
  ### Option 1: Use an Inference API model
78
 
79
+ This is a new option added recently, where you can use one of the models from the Hugging Face Hub. By default we suggest to use [zephyr-7b-beta](https://huggingface.co/HuggingFaceH4/zephyr-7b-beta) as it will provide better results than the 7b model.
80
 
81
  To activate it, create a `.env.local` configuration file:
82
 
 
85
 
86
  HF_API_TOKEN="Your Hugging Face token"
87
 
88
+ # "HuggingFaceH4/zephyr-7b-beta" is used by default, but you can change this
89
  # note: You should use a model able to generate JSON responses,
90
  # so it is storngly suggested to use at least the 34b model
91
+ HF_INFERENCE_API_MODEL="HuggingFaceH4/zephyr-7b-beta"
92
  ```
93
 
94
  ### Option 2: Use an Inference Endpoint URL
src/app/globals.css CHANGED
@@ -28,13 +28,12 @@ body {
28
 
29
  /* this is the trick to bypass the style={{}} attribute when printing */
30
  @media print {
31
- .comic-page, .comic-page[style] { width: 100vw !important; }
32
-
33
- /*if you have 2 panels, you will need replace this with landscape */
34
- @page { size: portrait }
35
  }
36
 
37
-
38
  .render-to-image .comic-panel {
39
  height: auto !important;
40
  /* max-width: fit-content !important; */
 
28
 
29
  /* this is the trick to bypass the style={{}} attribute when printing */
30
  @media print {
31
+ .comic-page, .comic-page[style] {
32
+ width: 100vw !important;
33
+ page-break-before: always;
34
+ }
35
  }
36
 
 
37
  .render-to-image .comic-panel {
38
  height: auto !important;
39
  /* max-width: fit-content !important; */
src/app/interface/about/index.tsx CHANGED
@@ -10,35 +10,35 @@ export function About() {
10
  <Dialog open={isOpen} onOpenChange={setOpen}>
11
  <DialogTrigger asChild>
12
  <Button variant="outline">
13
- <span className="hidden md:inline">About this project</span>
14
- <span className="inline md:hidden">About</span>
15
  </Button>
16
  </DialogTrigger>
17
  <DialogContent className="sm:max-w-[425px] md:max-w-[600px]">
18
  <DialogHeader>
19
- <DialogTitle>The AI Comic Factory</DialogTitle>
20
- <DialogDescription className="w-full text-center text-lg font-bold text-stone-800">
21
- What is the AI Comic Factory?
22
  </DialogDescription>
23
  </DialogHeader>
24
- <div className="grid gap-4 py-4 text-stone-800 text-sm">
25
- <p className="">
26
- The AI Comic Factory is an app to generate stories using AI in a few clicks.
27
- </p>
28
  <p>
29
- It is free for all Hugging Face users: <Login />
30
  </p>
31
- <p>
32
- As an artist, you can use your <a className="text-stone-600 underline" href="https://huggingface.co/spaces/jbilcke-hf/ai-comic-factory/discussions/402#654ab848fa25dfb780aa19fb" target="_blank">own art to generate comic panels.</a>
33
  </p>
34
  <p>
35
- πŸ‘‰ The language model used to generate the story is <a className="text-stone-600 underline" href="https://huggingface.co/HuggingFaceH4/zephyr-7b-beta" target="_blank">Zephyr-7b-beta</a>.
36
  </p>
37
  <p>
38
- πŸ‘‰ The diffusion model used by default is <a className="text-stone-600 underline" href="https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0" target="_blank">SDXL</a>.
39
  </p>
40
- <p>
41
- The code is public and can be deployed at home with some changes in the code. See the <a className="text-stone-600 underline" href="https://huggingface.co/spaces/jbilcke-hf/ai-comic-factory/blob/main/README.md" target="_blank">README</a> for details about the architecture.
42
  </p>
43
  </div>
44
  <DialogFooter>
 
10
  <Dialog open={isOpen} onOpenChange={setOpen}>
11
  <DialogTrigger asChild>
12
  <Button variant="outline">
13
+ <span className="hidden md:inline">AI-Comic-Factory 1.0</span>
14
+ <span className="inline md:hidden">Version 1.0</span>
15
  </Button>
16
  </DialogTrigger>
17
  <DialogContent className="sm:max-w-[425px] md:max-w-[600px]">
18
  <DialogHeader>
19
+ <DialogTitle>AI Comic Factory 1.0</DialogTitle>
20
+ <DialogDescription className="w-full text-center text-2xl font-bold text-stone-700">
21
+ AI Comic Factory 1.0 (March 2024 Update)
22
  </DialogDescription>
23
  </DialogHeader>
24
+ <div className="grid gap-4 py-4 text-stone-700 text-sm md:text-base xl:text-lg">
25
+ <p className="">
26
+ The AI Comic Factory generates stories using AI in a few clicks.
27
+ </p>
28
  <p>
29
+ App is free for Hugging Face users πŸ‘‰ <Login />
30
  </p>
31
+ <p className="pt-2 pb-2">
32
+ Are you an artist? Learn <a className="text-stone-600 underline" href="https://huggingface.co/spaces/jbilcke-hf/ai-comic-factory/discussions/402#654ab848fa25dfb780aa19fb" target="_blank">how to use your own art style</a>
33
  </p>
34
  <p>
35
+ πŸ‘‰ Default AI model used for stories is <a className="text-stone-600 underline" href="https://huggingface.co/HuggingFaceH4/zephyr-7b-beta" target="_blank">Zephyr-7b-beta</a>
36
  </p>
37
  <p>
38
+ πŸ‘‰ Default AI model used for drawing is <a className="text-stone-600 underline" href="https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0" target="_blank">SDXL</a> by Stability AI
39
  </p>
40
+ <p className="pt-2 pb-2">
41
+ This is an open-source project, see the <a className="text-stone-600 underline" href="https://huggingface.co/spaces/jbilcke-hf/ai-comic-factory/blob/main/README.md" target="_blank">README</a> for more info.
42
  </p>
43
  </div>
44
  <DialogFooter>
src/app/interface/page/index.tsx CHANGED
@@ -15,19 +15,20 @@ export function Page({ page }: { page: number}) {
15
  const LayoutElement = (allLayouts as any)[layout]
16
  const aspectRatio = ((allLayoutAspectRatios as any)[layout] as string) || "aspect-[250/297]"
17
 
18
- const nbPages = useStore(s => s.nbPages)
19
- const nbPanelsPerPage = useStore(s => s.nbPanelsPerPage)
 
20
 
21
  // in the future, different layouts might have different numbers of panels
22
  const allLayoutsNbPanels = {
23
- Layout0: nbPanelsPerPage,
24
- Layout1: nbPanelsPerPage,
25
- Layout2: nbPanelsPerPage,
26
- Layout3: nbPanelsPerPage,
27
- // Layout4: nbPanelsPerPage
28
  }
29
 
30
- const nbPanels = ((allLayoutsNbPanels as any)[layout] as number) || nbPanelsPerPage
31
 
32
  /*
33
  const [canLoad, setCanLoad] = useState(false)
@@ -50,6 +51,14 @@ export function Page({ page }: { page: number}) {
50
  setPage(element)
51
  }, [pageRef.current])
52
 
 
 
 
 
 
 
 
 
53
  return (
54
  <div
55
  ref={pageRef}
@@ -77,9 +86,9 @@ export function Page({ page }: { page: number}) {
77
  // marginLeft: `${zoomLevel > 100 ? `100`}`
78
  }}
79
  >
80
- <LayoutElement page={page} nbPanels={nbPanels} />
81
  </div>
82
- {nbPages > 1 &&
83
  <p className="w-full text-center pt-4 font-sans text-2xs font-semibold text-stone-600">
84
  Page {page + 1}
85
  {/*
 
15
  const LayoutElement = (allLayouts as any)[layout]
16
  const aspectRatio = ((allLayoutAspectRatios as any)[layout] as string) || "aspect-[250/297]"
17
 
18
+ const currentNbPages = useStore(s => s.currentNbPages)
19
+ const maxNbPages = useStore(s => s.maxNbPages)
20
+ const currentNbPanelsPerPage = useStore(s => s.currentNbPanelsPerPage)
21
 
22
  // in the future, different layouts might have different numbers of panels
23
  const allLayoutsNbPanels = {
24
+ Layout0: currentNbPanelsPerPage,
25
+ Layout1: currentNbPanelsPerPage,
26
+ Layout2: currentNbPanelsPerPage,
27
+ Layout3: currentNbPanelsPerPage,
28
+ // Layout4: currentNbPanelsPerPage
29
  }
30
 
31
+ const currentNbPanels = ((allLayoutsNbPanels as any)[layout] as number) || currentNbPanelsPerPage
32
 
33
  /*
34
  const [canLoad, setCanLoad] = useState(false)
 
51
  setPage(element)
52
  }, [pageRef.current])
53
 
54
+ /*
55
+ console.log("PAGE DEBUG:", {
56
+ currentNbPages,
57
+ maxNbPages,
58
+ "currentNbPages < maxNbPages": currentNbPages < maxNbPages,
59
+ })
60
+ */
61
+
62
  return (
63
  <div
64
  ref={pageRef}
 
86
  // marginLeft: `${zoomLevel > 100 ? `100`}`
87
  }}
88
  >
89
+ <LayoutElement page={page} nbPanels={currentNbPanels} />
90
  </div>
91
+ {currentNbPages > 1 &&
92
  <p className="w-full text-center pt-4 font-sans text-2xs font-semibold text-stone-600">
93
  Page {page + 1}
94
  {/*
src/app/interface/settings-dialog/defaultSettings.ts CHANGED
@@ -18,4 +18,5 @@ export const defaultSettings: Settings = {
18
  groqApiKey: "",
19
  groqApiLanguageModel: "mixtral-8x7b-32768",
20
  hasGeneratedAtLeastOnce: false,
 
21
  }
 
18
  groqApiKey: "",
19
  groqApiLanguageModel: "mixtral-8x7b-32768",
20
  hasGeneratedAtLeastOnce: false,
21
+ userDefinedMaxNumberOfPages: 1,
22
  }
src/app/interface/settings-dialog/getSettings.ts CHANGED
@@ -4,6 +4,7 @@ import { getValidString } from "@/lib/getValidString"
4
  import { localStorageKeys } from "./localStorageKeys"
5
  import { defaultSettings } from "./defaultSettings"
6
  import { getValidBoolean } from "@/lib/getValidBoolean"
 
7
 
8
  export function getSettings(): Settings {
9
  try {
@@ -24,7 +25,8 @@ export function getSettings(): Settings {
24
  openaiApiLanguageModel: getValidString(localStorage?.getItem?.(localStorageKeys.openaiApiLanguageModel), defaultSettings.openaiApiLanguageModel),
25
  groqApiKey: getValidString(localStorage?.getItem?.(localStorageKeys.groqApiKey), defaultSettings.groqApiKey),
26
  groqApiLanguageModel: getValidString(localStorage?.getItem?.(localStorageKeys.groqApiLanguageModel), defaultSettings.groqApiLanguageModel),
27
- hasGeneratedAtLeastOnce: getValidBoolean(localStorage?.getItem?.(localStorageKeys.hasGeneratedAtLeastOnce), defaultSettings.hasGeneratedAtLeastOnce),
 
28
  }
29
  } catch (err) {
30
  return {
 
4
  import { localStorageKeys } from "./localStorageKeys"
5
  import { defaultSettings } from "./defaultSettings"
6
  import { getValidBoolean } from "@/lib/getValidBoolean"
7
+ import { getValidNumber } from "@/lib/getValidNumber"
8
 
9
  export function getSettings(): Settings {
10
  try {
 
25
  openaiApiLanguageModel: getValidString(localStorage?.getItem?.(localStorageKeys.openaiApiLanguageModel), defaultSettings.openaiApiLanguageModel),
26
  groqApiKey: getValidString(localStorage?.getItem?.(localStorageKeys.groqApiKey), defaultSettings.groqApiKey),
27
  groqApiLanguageModel: getValidString(localStorage?.getItem?.(localStorageKeys.groqApiLanguageModel), defaultSettings.groqApiLanguageModel),
28
+ hasGeneratedAtLeastOnce: getValidBoolean(localStorage?.getItem?.(localStorageKeys.hasGeneratedAtLeastOnce), defaultSettings.hasGeneratedAtLeastOnce),
29
+ userDefinedMaxNumberOfPages: getValidNumber(localStorage?.getItem?.(localStorageKeys.userDefinedMaxNumberOfPages), 1, Number.MAX_SAFE_INTEGER, defaultSettings.userDefinedMaxNumberOfPages),
30
  }
31
  } catch (err) {
32
  return {
src/app/interface/settings-dialog/index.tsx CHANGED
@@ -1,3 +1,5 @@
 
 
1
  import { useState } from "react"
2
  import { useLocalStorage } from 'usehooks-ts'
3
 
@@ -18,8 +20,10 @@ import { Label } from "./label"
18
  import { Field } from "./field"
19
  import { localStorageKeys } from "./localStorageKeys"
20
  import { defaultSettings } from "./defaultSettings"
21
- import { Switch } from "@/components/ui/switch"
22
- import { cn } from "@/lib/utils"
 
 
23
 
24
  export function SettingsDialog() {
25
  const [isOpen, setOpen] = useState(false)
@@ -71,6 +75,12 @@ export function SettingsDialog() {
71
  localStorageKeys.openaiApiModel,
72
  defaultSettings.openaiApiModel
73
  )
 
 
 
 
 
 
74
 
75
  return (
76
  <Dialog open={isOpen} onOpenChange={setOpen}>
@@ -87,15 +97,32 @@ export function SettingsDialog() {
87
  Custom Models
88
  </DialogDescription>
89
  </DialogHeader>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  <div className="grid gap-4 py-1 space-y-1 text-stone-800">
91
- <p className="text-sm text-zinc-700">
92
- Note: most vendors have a warm-up delay when using a custom or rarely used model. Do not hesitate to try again after 5 minutes if that happens.
93
- </p>
94
- <p className="text-sm text-zinc-700">
95
- Security note: we do not save your API credentials on our server but inside your web browser, using the local storage.
96
- </p>
97
  <Field>
98
  <Label>Image rendering provider:</Label>
 
 
 
 
 
99
  <Select
100
  onValueChange={(value: string) => {
101
  setRenderingModelVendor(value as RenderingModelVendor)
@@ -115,36 +142,37 @@ export function SettingsDialog() {
115
 
116
 
117
  {
118
- renderingModelVendor === "SERVER" && <>
119
- <Field>
120
- <Label>Quality over performance ratio (beta, deprecated):</Label>
121
- <div className="flex flex-row space-x-2 text-zinc-500">
122
- <Switch
123
- // checked={renderingUseTurbo}
124
- // onCheckedChange={setRenderingUseTurbo}
125
- checked={false}
126
- disabled
127
- className="opacity-30 pointer-events-none"
128
- />
129
- {/*
130
- <span
131
- onClick={() => setRenderingUseTurbo(!renderingUseTurbo)}
132
- className={cn("cursor-pointer", { "text-zinc-800": renderingUseTurbo })}>
133
- Use a faster, but lower quality model (you are warned!)
134
- </span>
135
- */}
136
- <span className="text-zinc-500 italic">
137
- Following feedbacks from users (low rendering quality on comics) the fast renderer has been disabled.
138
- </span>
139
- </div>
140
- </Field>
141
- </>}
 
142
 
143
  {renderingModelVendor === "HUGGINGFACE" && <>
144
  <Field>
145
  <Label>Hugging Face API Token (<a className="text-stone-600 underline" href="https://huggingface.co/subscribe/pro" target="_blank">PRO account</a> recommended for higher rate limit):</Label>
146
  <Input
147
- className="font-mono"
148
  type="password"
149
  placeholder="Enter your private api token"
150
  onChange={(x) => {
@@ -156,7 +184,7 @@ export function SettingsDialog() {
156
  <Field>
157
  <Label>Inference API model (custom SDXL or SDXL LoRA):</Label>
158
  <Input
159
- className="font-mono"
160
  placeholder="Name of the Inference API model"
161
  onChange={(x) => {
162
  setHuggingfaceInferenceApiModel(x.target.value)
@@ -167,7 +195,7 @@ export function SettingsDialog() {
167
  <Field>
168
  <Label>The file type supported by the model (jpg, webp..):</Label>
169
  <Input
170
- className="font-mono"
171
  placeholder="Inference API file type"
172
  onChange={(x) => {
173
  setHuggingfaceInferenceApiFileType(x.target.value)
@@ -181,7 +209,7 @@ export function SettingsDialog() {
181
  <Field>
182
  <Label>LoRA model trigger (optional):</Label>
183
  <Input
184
- className="font-mono"
185
  placeholder="Trigger keyword (if you use a LoRA)"
186
  onChange={(x) => {
187
  setHuggingfaceInferenceApiModelTrigger(x.target.value)
@@ -195,7 +223,7 @@ export function SettingsDialog() {
195
  <Field>
196
  <Label>OpenAI API Token (you will be billed based on OpenAI pricing):</Label>
197
  <Input
198
- className="font-mono"
199
  type="password"
200
  placeholder="Enter your private api token"
201
  onChange={(x) => {
@@ -207,7 +235,7 @@ export function SettingsDialog() {
207
  <Field>
208
  <Label>OpenAI image model:</Label>
209
  <Input
210
- className="font-mono"
211
  placeholder="OpenAI image model"
212
  onChange={(x) => {
213
  setOpenaiApiModel(x.target.value)
@@ -221,7 +249,7 @@ export function SettingsDialog() {
221
  <Field>
222
  <Label>Replicate API Token (you will be billed based on Replicate pricing):</Label>
223
  <Input
224
- className="font-mono"
225
  type="password"
226
  placeholder="Enter your private api token"
227
  onChange={(x) => {
@@ -233,7 +261,7 @@ export function SettingsDialog() {
233
  <Field>
234
  <Label>Replicate model name:</Label>
235
  <Input
236
- className="font-mono"
237
  placeholder="Name of the Replicate model"
238
  onChange={(x) => {
239
  setReplicateApiModel(x.target.value)
@@ -244,7 +272,7 @@ export function SettingsDialog() {
244
  <Field>
245
  <Label>Model version:</Label>
246
  <Input
247
- className="font-mono"
248
  placeholder="Version of the Replicate model"
249
  onChange={(x) => {
250
  setReplicateApiModelVersion(x.target.value)
@@ -258,7 +286,7 @@ export function SettingsDialog() {
258
  <Field>
259
  <Label>LoRA model trigger (optional):</Label>
260
  <Input
261
- className="font-mono"
262
  placeholder={'Eg. "In the style of TOK" etc'}
263
  onChange={(x) => {
264
  setReplicateApiModelTrigger(x.target.value)
@@ -267,7 +295,12 @@ export function SettingsDialog() {
267
  />
268
  </Field>
269
  </>}
 
 
 
 
270
  </div>
 
271
  <DialogFooter>
272
  <Button type="submit" onClick={() => setOpen(false)}>Close</Button>
273
  </DialogFooter>
 
1
+ "use client"
2
+
3
  import { useState } from "react"
4
  import { useLocalStorage } from 'usehooks-ts'
5
 
 
20
  import { Field } from "./field"
21
  import { localStorageKeys } from "./localStorageKeys"
22
  import { defaultSettings } from "./defaultSettings"
23
+
24
+ import { useDynamicConfig } from "@/lib/useDynamicConfig"
25
+ import { Slider } from "@/components/ui/slider"
26
+ import { fonts } from "@/lib/fonts"
27
 
28
  export function SettingsDialog() {
29
  const [isOpen, setOpen] = useState(false)
 
75
  localStorageKeys.openaiApiModel,
76
  defaultSettings.openaiApiModel
77
  )
78
+ const [userDefinedMaxNumberOfPages, setUserDefinedMaxNumberOfPages] = useLocalStorage<number>(
79
+ localStorageKeys.userDefinedMaxNumberOfPages,
80
+ defaultSettings.userDefinedMaxNumberOfPages
81
+ )
82
+
83
+ const { config: { maxNbPages }, isConfigReady } = useDynamicConfig()
84
 
85
  return (
86
  <Dialog open={isOpen} onOpenChange={setOpen}>
 
97
  Custom Models
98
  </DialogDescription>
99
  </DialogHeader>
100
+ {
101
+ // isConfigReady && <Field>
102
+ // <Label>Maximum number of pages: {userDefinedMaxNumberOfPages}</Label>
103
+ // <Slider
104
+ // min={1}
105
+ // max={maxNbPages}
106
+ // step={1}
107
+ // onValueChange={(value: any) => {
108
+ // let numericValue = Number(value[0])
109
+ // numericValue = !isNaN(value[0]) && isFinite(value[0]) ? numericValue : 0
110
+ // numericValue = Math.min(maxNbPages, Math.max(1, numericValue))
111
+ // setUserDefinedMaxNumberOfPages(numericValue)
112
+ // }}
113
+ // defaultValue={[userDefinedMaxNumberOfPages]}
114
+ // value={[userDefinedMaxNumberOfPages]}
115
+ // />
116
+ // </Field>
117
+ }
118
  <div className="grid gap-4 py-1 space-y-1 text-stone-800">
 
 
 
 
 
 
119
  <Field>
120
  <Label>Image rendering provider:</Label>
121
+ <p className="pt-2 pb-3 text-base italic text-zinc-600">
122
+ ℹ️ Some API vendors have a delay for rarely used models.<br/>
123
+ πŸ‘‰ In case of trouble, try again after 5-10 minutes.
124
+ </p>
125
+
126
  <Select
127
  onValueChange={(value: string) => {
128
  setRenderingModelVendor(value as RenderingModelVendor)
 
142
 
143
 
144
  {
145
+ // renderingModelVendor === "SERVER" && <>
146
+ // <Field>
147
+ // <Label>Quality over performance ratio (beta, deprecated):</Label>
148
+ // <div className="flex flex-row space-x-2 text-zinc-500">
149
+ // <Switch
150
+ // // checked={renderingUseTurbo}
151
+ // // onCheckedChange={setRenderingUseTurbo}
152
+ // checked={false}
153
+ // disabled
154
+ // className="opacity-30 pointer-events-none"
155
+ // />
156
+ // {/*
157
+ // <span
158
+ // onClick={() => setRenderingUseTurbo(!renderingUseTurbo)}
159
+ // className={cn("cursor-pointer", { "text-zinc-800": renderingUseTurbo })}>
160
+ // Use a faster, but lower quality model (you are warned!)
161
+ // </span>
162
+ // */}
163
+ // <span className="text-zinc-500 italic">
164
+ // Following feedbacks from users (low rendering quality on comics) the fast renderer has been disabled.
165
+ // </span>
166
+ // </div>
167
+ // </Field>
168
+ // </>
169
+ }
170
 
171
  {renderingModelVendor === "HUGGINGFACE" && <>
172
  <Field>
173
  <Label>Hugging Face API Token (<a className="text-stone-600 underline" href="https://huggingface.co/subscribe/pro" target="_blank">PRO account</a> recommended for higher rate limit):</Label>
174
  <Input
175
+ className={fonts.actionman.className}
176
  type="password"
177
  placeholder="Enter your private api token"
178
  onChange={(x) => {
 
184
  <Field>
185
  <Label>Inference API model (custom SDXL or SDXL LoRA):</Label>
186
  <Input
187
+ className={fonts.actionman.className}
188
  placeholder="Name of the Inference API model"
189
  onChange={(x) => {
190
  setHuggingfaceInferenceApiModel(x.target.value)
 
195
  <Field>
196
  <Label>The file type supported by the model (jpg, webp..):</Label>
197
  <Input
198
+ className={fonts.actionman.className}
199
  placeholder="Inference API file type"
200
  onChange={(x) => {
201
  setHuggingfaceInferenceApiFileType(x.target.value)
 
209
  <Field>
210
  <Label>LoRA model trigger (optional):</Label>
211
  <Input
212
+ className={fonts.actionman.className}
213
  placeholder="Trigger keyword (if you use a LoRA)"
214
  onChange={(x) => {
215
  setHuggingfaceInferenceApiModelTrigger(x.target.value)
 
223
  <Field>
224
  <Label>OpenAI API Token (you will be billed based on OpenAI pricing):</Label>
225
  <Input
226
+ className={fonts.actionman.className}
227
  type="password"
228
  placeholder="Enter your private api token"
229
  onChange={(x) => {
 
235
  <Field>
236
  <Label>OpenAI image model:</Label>
237
  <Input
238
+ className={fonts.actionman.className}
239
  placeholder="OpenAI image model"
240
  onChange={(x) => {
241
  setOpenaiApiModel(x.target.value)
 
249
  <Field>
250
  <Label>Replicate API Token (you will be billed based on Replicate pricing):</Label>
251
  <Input
252
+ className={fonts.actionman.className}
253
  type="password"
254
  placeholder="Enter your private api token"
255
  onChange={(x) => {
 
261
  <Field>
262
  <Label>Replicate model name:</Label>
263
  <Input
264
+ className={fonts.actionman.className}
265
  placeholder="Name of the Replicate model"
266
  onChange={(x) => {
267
  setReplicateApiModel(x.target.value)
 
272
  <Field>
273
  <Label>Model version:</Label>
274
  <Input
275
+ className={fonts.actionman.className}
276
  placeholder="Version of the Replicate model"
277
  onChange={(x) => {
278
  setReplicateApiModelVersion(x.target.value)
 
286
  <Field>
287
  <Label>LoRA model trigger (optional):</Label>
288
  <Input
289
+ className={fonts.actionman.className}
290
  placeholder={'Eg. "In the style of TOK" etc'}
291
  onChange={(x) => {
292
  setReplicateApiModelTrigger(x.target.value)
 
295
  />
296
  </Field>
297
  </>}
298
+
299
+ <p className="text-sm text-zinc-700 italic">
300
+ πŸ”’ Settings such as API keys are stored inside your browser and aren&apos;t kept on our servers.
301
+ </p>
302
  </div>
303
+
304
  <DialogFooter>
305
  <Button type="submit" onClick={() => setOpen(false)}>Close</Button>
306
  </DialogFooter>
src/app/interface/settings-dialog/label.tsx CHANGED
@@ -2,6 +2,6 @@ import { ReactNode } from "react"
2
 
3
  export function Label({ children }: { children: ReactNode }) {
4
  return (
5
- <label className="text-base font-semibold text-zinc-700">{children}</label>
6
  )
7
  }
 
2
 
3
  export function Label({ children }: { children: ReactNode }) {
4
  return (
5
+ <label className="text-xl font-semibold text-zinc-700">{children}</label>
6
  )
7
  }
src/app/interface/settings-dialog/localStorageKeys.ts CHANGED
@@ -18,4 +18,5 @@ export const localStorageKeys: Record<keyof Settings, string> = {
18
  groqApiKey: "CONF_AUTH_GROQ_API_KEY",
19
  groqApiLanguageModel: "CONF_AUTH_GROQ_API_LANGUAGE_MODEL",
20
  hasGeneratedAtLeastOnce: "CONF_HAS_GENERATED_AT_LEAST_ONCE",
 
21
  }
 
18
  groqApiKey: "CONF_AUTH_GROQ_API_KEY",
19
  groqApiLanguageModel: "CONF_AUTH_GROQ_API_LANGUAGE_MODEL",
20
  hasGeneratedAtLeastOnce: "CONF_HAS_GENERATED_AT_LEAST_ONCE",
21
+ userDefinedMaxNumberOfPages: "CONF_USER_DEFINED_MAX_NUMBER_OF_PAGES"
22
  }
src/app/interface/sign-up-cta/index.tsx ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import dynamic from "next/dynamic";
4
+
5
+ export const SignUpCTA = dynamic(() => import("./sign-up-cta"), {
6
+ // Make sure we turn SSR off
7
+ ssr: false,
8
+ });
src/app/interface/sign-up-cta/sign-up-cta.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useOAuth } from "@/lib/useOAuth"
2
+ import { cn } from "@/lib/utils"
3
+
4
+ function SignUpCTA() {
5
+ const { login, isLoggedIn } = useOAuth({ debug: false })
6
+ if (isLoggedIn) { return null }
7
+ return (
8
+ <div className={cn(
9
+ `print:hidden`,
10
+ `fixed flex flex-col items-center bottom-8 top-28 right-2 md:top-17 md:right-6 z-10`,
11
+ )}>
12
+ <div className="font-bold text-sm pb-2 text-stone-600 bg-stone-50 dark:text-stone-600 dark:bg-stone-50 p-1 rounded-sm">
13
+ anonymous users can generate 1 comic.<br/> <span
14
+ onClick={login}
15
+ className="underline underline-offset-2 cursor-pointer text-sky-800 dark:text-sky-800 hover:text-sky-700 hover:dark:text-sky-700"
16
+ >Sign-up to Hugging Face</span> to make more!
17
+ </div>
18
+ </div>
19
+ )
20
+ }
21
+
22
+ export default SignUpCTA
src/app/interface/top-menu/index.tsx CHANGED
@@ -126,13 +126,14 @@ export function TopMenu() {
126
  `backdrop-blur-xl`,
127
  `transition-all duration-200 ease-in-out`,
128
  `px-2 py-2 border-b-1 border-gray-50 dark:border-gray-50`,
129
- `bg-stone-900/70 dark:bg-stone-900/70 text-gray-50 dark:text-gray-50`,
 
130
  `space-y-2 md:space-y-0 md:space-x-3 lg:space-x-6`
131
  )}>
132
  <div className="flex flex-row space-x-2 md:space-x-3 w-full md:w-auto">
133
  <div className={cn(
134
  `transition-all duration-200 ease-in-out`,
135
- `flex flex-row items-center justify-start space-x-3 font-mono`,
136
  `flex-grow`
137
  )}>
138
 
@@ -143,7 +144,7 @@ export function TopMenu() {
143
  onValueChange={(value) => { setDraftPreset(value as PresetName) }}
144
  disabled={isBusy}
145
  >
146
- <SelectTrigger className="flex-grow">
147
  <SelectValue className="text-2xs md:text-sm" placeholder="Style" />
148
  </SelectTrigger>
149
  <SelectContent>
@@ -155,7 +156,7 @@ export function TopMenu() {
155
  </div>
156
  <div className={cn(
157
  `transition-all duration-200 ease-in-out`,
158
- `flex flex-row items-center justify-start space-x-3 font-mono`,
159
  `w-40`
160
  )}>
161
 
@@ -166,13 +167,13 @@ export function TopMenu() {
166
  onValueChange={(value) => { setDraftLayout(value as LayoutName) }}
167
  disabled={isBusy}
168
  >
169
- <SelectTrigger className="flex-grow">
170
  <SelectValue className="text-2xs md:text-sm" placeholder="Layout" />
171
  </SelectTrigger>
172
  <SelectContent>
173
  {nonRandomLayouts.map(key =>
174
  <SelectItem key={key} value={key} className="w-full">
175
- <div className="space-x-6 flex flex-row items-center justify-between font-mono">
176
  <div className="flex">{
177
  (allLayoutLabels as any)[key]
178
  }</div>
@@ -197,7 +198,7 @@ export function TopMenu() {
197
  checked={showCaptions}
198
  onCheckedChange={setShowCaptions}
199
  />
200
- <Label>
201
  <span className="hidden md:inline">Caption</span>
202
  <span className="inline md:hidden">Cap.</span>
203
  </Label>
@@ -205,7 +206,7 @@ export function TopMenu() {
205
  {/*
206
  <div className={cn(
207
  `transition-all duration-200 ease-in-out`,
208
- `flex flex-row items-center space-x-3 font-mono w-1/2 md:w-auto md:hidden`
209
  )}>
210
  <Label className="flex text-2xs md:text-sm md:w-24">Font:</Label>
211
  <Select
@@ -232,13 +233,17 @@ export function TopMenu() {
232
  </div>
233
  <div className={cn(
234
  `transition-all duration-200 ease-in-out`,
235
- `flex flex-grow flex-col space-y-2 md:space-y-0 md:flex-row items-center md:space-x-3 font-mono w-full md:w-auto`
236
  )}>
237
  <div className="flex flex-row flex-grow w-full">
238
  <div className="flex flex-row flex-grow w-full">
239
  <Input
240
  placeholder="1. Story (eg. detective dog)"
241
- className="w-1/2 bg-neutral-300 text-neutral-800 dark:bg-neutral-300 dark:text-neutral-800 rounded-r-none border-r-stone-100"
 
 
 
 
242
  // disabled={atLeastOnePanelIsBusy}
243
  onChange={(e) => {
244
  setDraftPromptB(e.target.value)
@@ -252,7 +257,11 @@ export function TopMenu() {
252
  />
253
  <Input
254
  placeholder="2. Style (eg 'rain, shiba')"
255
- className="w-1/2 bg-neutral-300 text-neutral-800 dark:bg-neutral-300 dark:text-neutral-800 border-l-stone-100 rounded-l-none rounded-r-none"
 
 
 
 
256
  // disabled={atLeastOnePanelIsBusy}
257
  onChange={(e) => {
258
  setDraftPromptA(e.target.value)
@@ -269,6 +278,7 @@ export function TopMenu() {
269
  className={cn(
270
  `rounded-l-none cursor-pointer`,
271
  `transition-all duration-200 ease-in-out`,
 
272
  `bg-[rgb(59,134,247)] hover:bg-[rgb(69,144,255)] disabled:bg-[rgb(59,134,247)]`
273
  )}
274
  onClick={() => {
@@ -287,7 +297,7 @@ export function TopMenu() {
287
  are confused about why they can't activate it
288
  <div className={cn(
289
  `transition-all duration-200 ease-in-out`,
290
- `hidden md:flex flex-row items-center space-x-3 font-mono w-full md:w-auto`
291
  )}>
292
  <Label className="flex text-2xs md:text-sm w-24">Font:</Label>
293
  <Select
 
126
  `backdrop-blur-xl`,
127
  `transition-all duration-200 ease-in-out`,
128
  `px-2 py-2 border-b-1 border-gray-50 dark:border-gray-50`,
129
+ //`bg-[#2d435c] dark:bg-[#2d435c] text-gray-50 dark:text-gray-50`,
130
+ `bg-gradient-to-r from-[#102c4c] to-[#1a426f] dark:bg-gradient-to-r dark:from-[#102c4c] dark:to-[#1a426f]`,
131
  `space-y-2 md:space-y-0 md:space-x-3 lg:space-x-6`
132
  )}>
133
  <div className="flex flex-row space-x-2 md:space-x-3 w-full md:w-auto">
134
  <div className={cn(
135
  `transition-all duration-200 ease-in-out`,
136
+ `flex flex-row items-center justify-start space-x-3`,
137
  `flex-grow`
138
  )}>
139
 
 
144
  onValueChange={(value) => { setDraftPreset(value as PresetName) }}
145
  disabled={isBusy}
146
  >
147
+ <SelectTrigger className="flex-grow bg-gray-100 text-gray-700 dark:bg-gray-100 dark:text-gray-700">
148
  <SelectValue className="text-2xs md:text-sm" placeholder="Style" />
149
  </SelectTrigger>
150
  <SelectContent>
 
156
  </div>
157
  <div className={cn(
158
  `transition-all duration-200 ease-in-out`,
159
+ `flex flex-row items-center justify-start space-x-3`,
160
  `w-40`
161
  )}>
162
 
 
167
  onValueChange={(value) => { setDraftLayout(value as LayoutName) }}
168
  disabled={isBusy}
169
  >
170
+ <SelectTrigger className="flex-grow bg-gray-100 text-gray-700 dark:bg-gray-100 dark:text-gray-700">
171
  <SelectValue className="text-2xs md:text-sm" placeholder="Layout" />
172
  </SelectTrigger>
173
  <SelectContent>
174
  {nonRandomLayouts.map(key =>
175
  <SelectItem key={key} value={key} className="w-full">
176
+ <div className="space-x-6 flex flex-row items-center justify-between">
177
  <div className="flex">{
178
  (allLayoutLabels as any)[key]
179
  }</div>
 
198
  checked={showCaptions}
199
  onCheckedChange={setShowCaptions}
200
  />
201
+ <Label className="text-gray-200 dark:text-gray-200">
202
  <span className="hidden md:inline">Caption</span>
203
  <span className="inline md:hidden">Cap.</span>
204
  </Label>
 
206
  {/*
207
  <div className={cn(
208
  `transition-all duration-200 ease-in-out`,
209
+ `flex flex-row items-center space-x-3 w-1/2 md:w-auto md:hidden`
210
  )}>
211
  <Label className="flex text-2xs md:text-sm md:w-24">Font:</Label>
212
  <Select
 
233
  </div>
234
  <div className={cn(
235
  `transition-all duration-200 ease-in-out`,
236
+ `flex flex-grow flex-col space-y-2 md:space-y-0 md:flex-row items-center md:space-x-3 w-full md:w-auto`
237
  )}>
238
  <div className="flex flex-row flex-grow w-full">
239
  <div className="flex flex-row flex-grow w-full">
240
  <Input
241
  placeholder="1. Story (eg. detective dog)"
242
+ className={cn(
243
+ `w-1/2 rounded-r-none`,
244
+ `bg-gray-100 text-gray-700 dark:bg-gray-100 dark:text-gray-700`,
245
+ `border-r-stone-100`
246
+ )}
247
  // disabled={atLeastOnePanelIsBusy}
248
  onChange={(e) => {
249
  setDraftPromptB(e.target.value)
 
257
  />
258
  <Input
259
  placeholder="2. Style (eg 'rain, shiba')"
260
+ className={cn(
261
+ `w-1/2`,
262
+ `bg-gray-100 text-gray-700 dark:bg-gray-100 dark:text-gray-700`,
263
+ `border-l-gray-300 rounded-l-none rounded-r-none`
264
+ )}
265
  // disabled={atLeastOnePanelIsBusy}
266
  onChange={(e) => {
267
  setDraftPromptA(e.target.value)
 
278
  className={cn(
279
  `rounded-l-none cursor-pointer`,
280
  `transition-all duration-200 ease-in-out`,
281
+ `text-xl`,
282
  `bg-[rgb(59,134,247)] hover:bg-[rgb(69,144,255)] disabled:bg-[rgb(59,134,247)]`
283
  )}
284
  onClick={() => {
 
297
  are confused about why they can't activate it
298
  <div className={cn(
299
  `transition-all duration-200 ease-in-out`,
300
+ `hidden md:flex flex-row items-center space-x-3 w-full md:w-auto`
301
  )}>
302
  <Label className="flex text-2xs md:text-sm w-24">Font:</Label>
303
  <Select
src/app/interface/zoom/index.tsx CHANGED
@@ -11,11 +11,11 @@ export function Zoom() {
11
  <div className={cn(
12
  `print:hidden`,
13
  // `fixed flex items-center justify-center bottom-8 top-32 right-8 z-10 h-screen`,
14
- `fixed flex flex-col items-center bottom-8 top-28 right-2 md:top-20 md:right-6 z-10`,
15
  `animation-all duration-300 ease-in-out`,
16
  isGeneratingStory ? `scale-0 opacity-0` : ``,
17
  )}>
18
- <div className="font-mono font-bold text-xs pb-2 text-stone-600 bg-stone-50 p-1 rounded-sm">
19
  Zoom
20
  </div>
21
  <div className="w-2">
 
11
  <div className={cn(
12
  `print:hidden`,
13
  // `fixed flex items-center justify-center bottom-8 top-32 right-8 z-10 h-screen`,
14
+ `fixed flex flex-col items-center bottom-8 top-40 right-2 md:top-28 md:right-6 z-10`,
15
  `animation-all duration-300 ease-in-out`,
16
  isGeneratingStory ? `scale-0 opacity-0` : ``,
17
  )}>
18
+ <div className="font-bold text-xs pb-2 text-stone-600 bg-stone-50 dark:text-stone-600 dark:bg-stone-50 p-1 rounded-sm">
19
  Zoom
20
  </div>
21
  <div className="w-2">
src/app/layout.tsx CHANGED
@@ -1,8 +1,6 @@
 
1
  import './globals.css'
2
  import type { Metadata } from 'next'
3
- import { Inter } from 'next/font/google'
4
-
5
- const inter = Inter({ subsets: ['latin'] })
6
 
7
  export const metadata: Metadata = {
8
  title: 'AI Comic Factory: generate your own comics! Powered by Hugging Face πŸ€—',
@@ -16,7 +14,7 @@ export default function RootLayout({
16
  }) {
17
  return (
18
  <html lang="en">
19
- <body className={inter.className}>
20
  {children}
21
  </body>
22
  </html>
 
1
+ import { fonts } from '@/lib/fonts'
2
  import './globals.css'
3
  import type { Metadata } from 'next'
 
 
 
4
 
5
  export const metadata: Metadata = {
6
  title: 'AI Comic Factory: generate your own comics! Powered by Hugging Face πŸ€—',
 
14
  }) {
15
  return (
16
  <html lang="en">
17
+ <body className={fonts.actionman.className}>
18
  {children}
19
  </body>
20
  </html>
src/app/main.tsx CHANGED
@@ -14,6 +14,11 @@ import { BottomBar } from "./interface/bottom-bar"
14
  import { Page } from "./interface/page"
15
  import { getStoryContinuation } from "./queries/getStoryContinuation"
16
  import { useDynamicConfig } from "@/lib/useDynamicConfig"
 
 
 
 
 
17
 
18
  export default function Main() {
19
  const [_isPending, startTransition] = useTransition()
@@ -26,11 +31,19 @@ export default function Main() {
26
  const preset = useStore(s => s.preset)
27
  const prompt = useStore(s => s.prompt)
28
 
29
- const nbPages = useStore(s => s.nbPages)
30
- const nbPanelsPerPage = useStore(s => s.nbPanelsPerPage)
31
- const nbTotalPanels = useStore(s => s.nbTotalPanels)
32
- const setNbPages = useStore(s => s.setNbPages)
33
- const setNbPanelsPerPage = useStore(s => s.setNbPanelsPerPage)
 
 
 
 
 
 
 
 
34
 
35
  const setPanels = useStore(s => s.setPanels)
36
  const setCaptions = useStore(s => s.setCaptions)
@@ -39,12 +52,28 @@ export default function Main() {
39
 
40
  const [waitABitMore, setWaitABitMore] = useState(false)
41
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  useEffect(() => {
43
  if (isConfigReady) {
44
- setNbPages(config.maxNbPages)
45
- setNbPanelsPerPage(config.nbPanelsPerPage)
 
 
 
46
  }
47
  }, [JSON.stringify(config), isConfigReady])
 
48
  // react to prompt changes
49
  useEffect(() => {
50
  if (!prompt) { return }
@@ -85,7 +114,7 @@ export default function Main() {
85
 
86
  for (
87
  let currentPanel = 0;
88
- currentPanel < nbTotalPanels;
89
  currentPanel += nbPanelsToGenerate
90
  ) {
91
  try {
@@ -94,7 +123,7 @@ export default function Main() {
94
  stylePrompt,
95
  userStoryPrompt,
96
  nbPanelsToGenerate,
97
- nbTotalPanels,
98
  existingPanels,
99
  })
100
  console.log("LLM generated some new panels:", candidatePanels)
@@ -133,7 +162,7 @@ export default function Main() {
133
  setGeneratingStory(false)
134
  break
135
  }
136
- if (currentPanel > (nbTotalPanels / 2)) {
137
  console.log("good, we are half way there, hold tight!")
138
  // setWaitABitMore(true)
139
  }
@@ -147,7 +176,7 @@ export default function Main() {
147
  */
148
 
149
  })
150
- }, [prompt, preset?.label, nbPages, nbPanelsPerPage, nbTotalPanels]) // important: we need to react to preset changes too
151
 
152
  return (
153
  <Suspense>
@@ -173,10 +202,18 @@ export default function Main() {
173
  style={{
174
  width: `${zoomLevel}%`
175
  }}>
176
- {Array(nbPages).fill(0).map((_, i) => <Page key={i} page={i} />)}
177
  </div>
 
 
 
 
 
 
 
178
  </div>
179
  </div>
 
180
  <Zoom />
181
  <BottomBar />
182
  <div className={cn(
 
14
  import { Page } from "./interface/page"
15
  import { getStoryContinuation } from "./queries/getStoryContinuation"
16
  import { useDynamicConfig } from "@/lib/useDynamicConfig"
17
+ import { useLocalStorage } from "usehooks-ts"
18
+ import { localStorageKeys } from "./interface/settings-dialog/localStorageKeys"
19
+ import { defaultSettings } from "./interface/settings-dialog/defaultSettings"
20
+ import { Button } from "@/components/ui/button"
21
+ import { SignUpCTA } from "./interface/sign-up-cta"
22
 
23
  export default function Main() {
24
  const [_isPending, startTransition] = useTransition()
 
31
  const preset = useStore(s => s.preset)
32
  const prompt = useStore(s => s.prompt)
33
 
34
+ const currentNbPanelsPerPage = useStore(s => s.currentNbPanelsPerPage)
35
+ const maxNbPanelsPerPage = useStore(s => s.maxNbPanelsPerPage)
36
+ const currentNbPages = useStore(s => s.currentNbPages)
37
+ const maxNbPages = useStore(s => s.maxNbPages)
38
+ const currentNbPanels = useStore(s => s.currentNbPanels)
39
+ const maxNbPanels = useStore(s => s.maxNbPanels)
40
+
41
+ const setCurrentNbPanelsPerPage = useStore(s => s.setCurrentNbPanelsPerPage)
42
+ const setMaxNbPanelsPerPage = useStore(s => s.setMaxNbPanelsPerPage)
43
+ const setCurrentNbPages = useStore(s => s.setCurrentNbPages)
44
+ const setMaxNbPages = useStore(s => s.setMaxNbPages)
45
+ const setCurrentNbPanels = useStore(s => s.setCurrentNbPanels)
46
+ const setMaxNbPanels = useStore(s => s.setMaxNbPanels)
47
 
48
  const setPanels = useStore(s => s.setPanels)
49
  const setCaptions = useStore(s => s.setCaptions)
 
52
 
53
  const [waitABitMore, setWaitABitMore] = useState(false)
54
 
55
+ const [userDefinedMaxNumberOfPages, setUserDefinedMaxNumberOfPages] = useLocalStorage<number>(
56
+ localStorageKeys.userDefinedMaxNumberOfPages,
57
+ defaultSettings.userDefinedMaxNumberOfPages
58
+ )
59
+
60
+ useEffect(() => {
61
+ if (maxNbPages !== userDefinedMaxNumberOfPages) {
62
+ setMaxNbPages(userDefinedMaxNumberOfPages)
63
+ }
64
+ }, [maxNbPages, userDefinedMaxNumberOfPages])
65
+
66
+
67
  useEffect(() => {
68
  if (isConfigReady) {
69
+
70
+ // note: this has very low impact at the moment as we are always using the value 4
71
+ // however I would like to progressively evolve the code to make it dynamic
72
+ setCurrentNbPanelsPerPage(config.nbPanelsPerPage)
73
+ setMaxNbPanelsPerPage(config.nbPanelsPerPage)
74
  }
75
  }, [JSON.stringify(config), isConfigReady])
76
+
77
  // react to prompt changes
78
  useEffect(() => {
79
  if (!prompt) { return }
 
114
 
115
  for (
116
  let currentPanel = 0;
117
+ currentPanel < currentNbPanels;
118
  currentPanel += nbPanelsToGenerate
119
  ) {
120
  try {
 
123
  stylePrompt,
124
  userStoryPrompt,
125
  nbPanelsToGenerate,
126
+ maxNbPanels,
127
  existingPanels,
128
  })
129
  console.log("LLM generated some new panels:", candidatePanels)
 
162
  setGeneratingStory(false)
163
  break
164
  }
165
+ if (currentPanel > (currentNbPanels / 2)) {
166
  console.log("good, we are half way there, hold tight!")
167
  // setWaitABitMore(true)
168
  }
 
176
  */
177
 
178
  })
179
+ }, [prompt, preset?.label, currentNbPanels, maxNbPanels]) // important: we need to react to preset changes too
180
 
181
  return (
182
  <Suspense>
 
202
  style={{
203
  width: `${zoomLevel}%`
204
  }}>
205
+ {Array(currentNbPages).fill(0).map((_, i) => <Page key={i} page={i} />)}
206
  </div>
207
+ {
208
+ // currentNbPages < maxNbPages &&
209
+ // <div className="flex flex-col space-y-2 pt-2 pb-6 text-gray-600 dark:text-gray-600">
210
+ // <div>Happy with your story?</div>
211
+ // <div>You can <Button>Add page {currentNbPages + 1} πŸ‘€</Button></div>
212
+ // </div>
213
+ }
214
  </div>
215
  </div>
216
+ <SignUpCTA />
217
  <Zoom />
218
  <BottomBar />
219
  <div className={cn(
src/app/queries/getDynamicConfig.ts CHANGED
@@ -6,8 +6,8 @@ import { getValidString } from "@/lib/getValidString"
6
  import { DynamicConfig } from "@/types"
7
 
8
  export async function getDynamicConfig(): Promise<DynamicConfig> {
9
- const maxNbPages = getValidNumber(process.env.MAX_NB_PAGES, 1, 16, 1)
10
- const nbPanelsPerPage = 4
11
  const nbTotalPanelsToGenerate = maxNbPages * nbPanelsPerPage
12
 
13
  const config = {
 
6
  import { DynamicConfig } from "@/types"
7
 
8
  export async function getDynamicConfig(): Promise<DynamicConfig> {
9
+ const maxNbPages = getValidNumber(process.env.MAX_NB_PAGES, 1, Number.MAX_SAFE_INTEGER, 1)
10
+ const nbPanelsPerPage = 4 // for now this is static
11
  const nbTotalPanelsToGenerate = maxNbPages * nbPanelsPerPage
12
 
13
  const config = {
src/app/queries/getStoryContinuation.ts CHANGED
@@ -8,14 +8,14 @@ export const getStoryContinuation = async ({
8
  stylePrompt = "",
9
  userStoryPrompt = "",
10
  nbPanelsToGenerate = 2,
11
- nbTotalPanels = 4,
12
  existingPanels = [],
13
  }: {
14
  preset: Preset;
15
  stylePrompt?: string;
16
  userStoryPrompt?: string;
17
  nbPanelsToGenerate?: number;
18
- nbTotalPanels?: number;
19
  existingPanels?: GeneratedPanel[];
20
  }): Promise<GeneratedPanel[]> => {
21
 
@@ -31,7 +31,7 @@ export const getStoryContinuation = async ({
31
  preset,
32
  prompt,
33
  nbPanelsToGenerate,
34
- nbTotalPanels,
35
  existingPanels,
36
  })
37
 
 
8
  stylePrompt = "",
9
  userStoryPrompt = "",
10
  nbPanelsToGenerate = 2,
11
+ maxNbPanels = 4,
12
  existingPanels = [],
13
  }: {
14
  preset: Preset;
15
  stylePrompt?: string;
16
  userStoryPrompt?: string;
17
  nbPanelsToGenerate?: number;
18
+ maxNbPanels?: number;
19
  existingPanels?: GeneratedPanel[];
20
  }): Promise<GeneratedPanel[]> => {
21
 
 
31
  preset,
32
  prompt,
33
  nbPanelsToGenerate,
34
+ maxNbPanels,
35
  existingPanels,
36
  })
37
 
src/app/queries/predictNextPanels.ts CHANGED
@@ -12,13 +12,13 @@ export const predictNextPanels = async ({
12
  preset,
13
  prompt = "",
14
  nbPanelsToGenerate = 2,
15
- nbTotalPanels = 4,
16
  existingPanels = [],
17
  }: {
18
  preset: Preset;
19
  prompt: string;
20
  nbPanelsToGenerate?: number;
21
- nbTotalPanels?: number;
22
  existingPanels: GeneratedPanel[];
23
  }): Promise<GeneratedPanel[]> => {
24
  // console.log("predictNextPanels: ", { prompt, nbPanelsToGenerate })
@@ -35,7 +35,7 @@ export const predictNextPanels = async ({
35
  const firstNextOrLast =
36
  existingPanels.length === 0
37
  ? "first"
38
- : (nbTotalPanels - existingPanels.length) === nbTotalPanels
39
  ? "last"
40
  : "next"
41
 
@@ -44,7 +44,7 @@ export const predictNextPanels = async ({
44
  role: "system",
45
  content: [
46
  `You are a writer specialized in ${preset.llmPrompt}`,
47
- `Please write detailed drawing instructions and short (2-3 sentences long) speech captions for the ${firstNextOrLast} ${nbPanelsToGenerate} panels (out of ${nbTotalPanels} in total) of a new story, but keep it open-ended (it will be continued and expanded later). Please make sure each of those ${nbPanelsToGenerate} panels include info about character gender, age, origin, clothes, colors, location, lights, etc. Only generate those ${nbPanelsToGenerate} panels, but take into account the fact the panels are part of a longer story (${nbTotalPanels} panels long).`,
48
  `Give your response as a VALID JSON array like this: \`Array<{ panel: number; instructions: string; caption: string; }>\`.`,
49
  // `Give your response as Markdown bullet points.`,
50
  `Be brief in the instructions and narrative captions of those ${nbPanelsToGenerate} panels, don't add your own comments. The captions must be captivating, smart, entertaining. Be straight to the point, and never reply things like "Sure, I can.." etc. Reply using valid JSON!! Important: Write valid JSON!`
 
12
  preset,
13
  prompt = "",
14
  nbPanelsToGenerate = 2,
15
+ maxNbPanels = 4,
16
  existingPanels = [],
17
  }: {
18
  preset: Preset;
19
  prompt: string;
20
  nbPanelsToGenerate?: number;
21
+ maxNbPanels?: number;
22
  existingPanels: GeneratedPanel[];
23
  }): Promise<GeneratedPanel[]> => {
24
  // console.log("predictNextPanels: ", { prompt, nbPanelsToGenerate })
 
35
  const firstNextOrLast =
36
  existingPanels.length === 0
37
  ? "first"
38
+ : (maxNbPanels - existingPanels.length) === maxNbPanels
39
  ? "last"
40
  : "next"
41
 
 
44
  role: "system",
45
  content: [
46
  `You are a writer specialized in ${preset.llmPrompt}`,
47
+ `Please write detailed drawing instructions and short (2-3 sentences long) speech captions for the ${firstNextOrLast} ${nbPanelsToGenerate} panels (out of ${maxNbPanels} in total) of a new story, but keep it open-ended (it will be continued and expanded later). Please make sure each of those ${nbPanelsToGenerate} panels include info about character gender, age, origin, clothes, colors, location, lights, etc. Only generate those ${nbPanelsToGenerate} panels, but take into account the fact the panels are part of a longer story (${maxNbPanels} panels long).`,
48
  `Give your response as a VALID JSON array like this: \`Array<{ panel: number; instructions: string; caption: string; }>\`.`,
49
  // `Give your response as Markdown bullet points.`,
50
  `Be brief in the instructions and narrative captions of those ${nbPanelsToGenerate} panels, don't add your own comments. The captions must be captivating, smart, entertaining. Be straight to the point, and never reply things like "Sure, I can.." etc. Reply using valid JSON!! Important: Write valid JSON!`
src/app/store/index.ts CHANGED
@@ -12,9 +12,12 @@ export const useStore = create<{
12
  prompt: string
13
  font: FontName
14
  preset: Preset
15
- nbPanelsPerPage: number
16
- nbPages: number
17
- nbTotalPanels: number
 
 
 
18
  panels: string[]
19
  captions: string[]
20
  upscaleQueue: Record<string, RenderedScene>
@@ -28,9 +31,14 @@ export const useStore = create<{
28
  panelGenerationStatus: Record<number, boolean>
29
  isGeneratingText: boolean
30
  atLeastOnePanelIsBusy: boolean
31
- setNbPanelsPerPage: (nbPanelsPerPage: number) => void
32
- setNbPages: (nbPages: number) => void
33
- setTotalPanels: (nbTotalPanels: number) => void
 
 
 
 
 
34
  setRendered: (panelId: string, renderedScene: RenderedScene) => void
35
  addToUpscaleQueue: (panelId: string, renderedScene: RenderedScene) => void
36
  removeFromUpscaleQueue: (panelId: string) => void
@@ -56,9 +64,14 @@ export const useStore = create<{
56
  prompt: "",
57
  font: "actionman",
58
  preset: getPreset(defaultPreset),
59
- nbPanelsPerPage: 4,
60
- nbPages: 1,
61
- nbTotalPanels: 4,
 
 
 
 
 
62
  panels: [],
63
  captions: [],
64
  upscaleQueue: {} as Record<string, RenderedScene>,
@@ -72,25 +85,47 @@ export const useStore = create<{
72
  panelGenerationStatus: {},
73
  isGeneratingText: false,
74
  atLeastOnePanelIsBusy: false,
75
- setNbPanelsPerPage: (nbPanelsPerPage: number) => {
76
- const { nbPages } = get()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  set({
78
- nbPanelsPerPage,
79
- nbTotalPanels: nbPanelsPerPage * nbPages,
80
  })
81
  },
82
- setNbPages: (nbPages: number) => {
83
- const { nbPanelsPerPage } = get()
84
  set({
85
- nbPages,
86
- nbTotalPanels: nbPanelsPerPage * nbPages,
87
  })
88
  },
89
- setTotalPanels: (nbTotalPanels: number) => {
90
  set({
91
- nbTotalPanels,
92
  })
93
  },
 
94
  setRendered: (panelId: string, renderedScene: RenderedScene) => {
95
  const { renderedScenes } = get()
96
  set({
@@ -166,14 +201,14 @@ export const useStore = create<{
166
  },
167
  setLayout: (layoutName: LayoutName) => {
168
 
169
- const { nbPages } = get()
170
 
171
  const layout = layoutName === "random"
172
  ? getRandomLayoutName()
173
  : layoutName
174
 
175
  const layouts: LayoutName[] = []
176
- for (let i = 0; i < nbPages; i++) {
177
  layouts.push(
178
  layoutName === "random"
179
  ? getRandomLayoutName()
@@ -215,9 +250,9 @@ export const useStore = create<{
215
 
216
 
217
  const canvas = await html2canvas(page)
218
- console.log("canvas:", canvas)
219
 
220
- const data = canvas.toDataURL('image/jpeg', 0.5)
221
  return data
222
  },
223
  download: async () => {
@@ -238,14 +273,14 @@ export const useStore = create<{
238
  },
239
  generate: (prompt: string, presetName: PresetName, layoutName: LayoutName) => {
240
 
241
- const { nbPages } = get()
242
 
243
  const layout = layoutName === "random"
244
  ? getRandomLayoutName()
245
  : layoutName
246
 
247
  const layouts: LayoutName[] = []
248
- for (let i = 0; i < nbPages; i++) {
249
  layouts.push(
250
  layoutName === "random"
251
  ? getRandomLayoutName()
 
12
  prompt: string
13
  font: FontName
14
  preset: Preset
15
+ currentNbPanelsPerPage: number
16
+ maxNbPanelsPerPage: number
17
+ currentNbPages: number
18
+ maxNbPages: number
19
+ currentNbPanels: number
20
+ maxNbPanels: number
21
  panels: string[]
22
  captions: string[]
23
  upscaleQueue: Record<string, RenderedScene>
 
31
  panelGenerationStatus: Record<number, boolean>
32
  isGeneratingText: boolean
33
  atLeastOnePanelIsBusy: boolean
34
+
35
+ setCurrentNbPanelsPerPage: (currentNbPanelsPerPage: number) => void
36
+ setMaxNbPanelsPerPage: (maxNbPanelsPerPage: number) => void
37
+ setCurrentNbPages: (currentNbPages: number) => void
38
+ setMaxNbPages: (maxNbPages: number) => void
39
+ setCurrentNbPanels: (currentNbPanels: number) => void
40
+ setMaxNbPanels: (maxNbPanels: number) => void
41
+
42
  setRendered: (panelId: string, renderedScene: RenderedScene) => void
43
  addToUpscaleQueue: (panelId: string, renderedScene: RenderedScene) => void
44
  removeFromUpscaleQueue: (panelId: string) => void
 
64
  prompt: "",
65
  font: "actionman",
66
  preset: getPreset(defaultPreset),
67
+
68
+ currentNbPanelsPerPage: 4,
69
+ maxNbPanelsPerPage: 4,
70
+ currentNbPages: 1,
71
+ maxNbPages: 1,
72
+ currentNbPanels: 4,
73
+ maxNbPanels: 4,
74
+
75
  panels: [],
76
  captions: [],
77
  upscaleQueue: {} as Record<string, RenderedScene>,
 
85
  panelGenerationStatus: {},
86
  isGeneratingText: false,
87
  atLeastOnePanelIsBusy: false,
88
+
89
+
90
+ setCurrentNbPanelsPerPage: (currentNbPanelsPerPage: number) => {
91
+ const { currentNbPages } = get()
92
+ set({
93
+ currentNbPanelsPerPage,
94
+ currentNbPanels: currentNbPanelsPerPage * currentNbPages
95
+ })
96
+ },
97
+ setMaxNbPanelsPerPage: (maxNbPanelsPerPage: number) => {
98
+ const { maxNbPages } = get()
99
+ set({
100
+ maxNbPanelsPerPage,
101
+ maxNbPanels: maxNbPanelsPerPage * maxNbPages,
102
+ })
103
+ },
104
+ setCurrentNbPages: (currentNbPages: number) => {
105
+ const { currentNbPanelsPerPage } = get()
106
+ set({
107
+ currentNbPages,
108
+ currentNbPanels: currentNbPanelsPerPage * currentNbPages
109
+ })
110
+ },
111
+ setMaxNbPages: (maxNbPages: number) => {
112
+ const { maxNbPanelsPerPage } = get()
113
  set({
114
+ maxNbPages,
115
+ maxNbPanels: maxNbPanelsPerPage * maxNbPages,
116
  })
117
  },
118
+ setCurrentNbPanels: (currentNbPanels: number) => {
 
119
  set({
120
+ currentNbPanels,
 
121
  })
122
  },
123
+ setMaxNbPanels: (maxNbPanels: number) => {
124
  set({
125
+ maxNbPanels
126
  })
127
  },
128
+
129
  setRendered: (panelId: string, renderedScene: RenderedScene) => {
130
  const { renderedScenes } = get()
131
  set({
 
201
  },
202
  setLayout: (layoutName: LayoutName) => {
203
 
204
+ const { currentNbPages } = get()
205
 
206
  const layout = layoutName === "random"
207
  ? getRandomLayoutName()
208
  : layoutName
209
 
210
  const layouts: LayoutName[] = []
211
+ for (let i = 0; i < currentNbPages; i++) {
212
  layouts.push(
213
  layoutName === "random"
214
  ? getRandomLayoutName()
 
250
 
251
 
252
  const canvas = await html2canvas(page)
253
+ // console.log("canvas:", canvas)
254
 
255
+ const data = canvas.toDataURL('image/jpeg', 0.97)
256
  return data
257
  },
258
  download: async () => {
 
273
  },
274
  generate: (prompt: string, presetName: PresetName, layoutName: LayoutName) => {
275
 
276
+ const { currentNbPages } = get()
277
 
278
  const layout = layoutName === "random"
279
  ? getRandomLayoutName()
280
  : layoutName
281
 
282
  const layouts: LayoutName[] = []
283
+ for (let i = 0; i < currentNbPages; i++) {
284
  layouts.push(
285
  layoutName === "random"
286
  ? getRandomLayoutName()
src/components/ui/dialog.tsx CHANGED
@@ -41,7 +41,7 @@ const DialogContent = React.forwardRef<
41
  <DialogPrimitive.Content
42
  ref={ref}
43
  className={cn(
44
- "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-stone-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full dark:border-stone-800 dark:bg-stone-950",
45
  className
46
  )}
47
  {...props}
 
41
  <DialogPrimitive.Content
42
  ref={ref}
43
  className={cn(
44
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-stone-200 bg-white p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-3xl md:w-full dark:border-stone-800 dark:bg-stone-950",
45
  className
46
  )}
47
  {...props}
src/components/ui/select.tsx CHANGED
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
19
  <SelectPrimitive.Trigger
20
  ref={ref}
21
  className={cn(
22
- "flex h-10 w-full items-center justify-between rounded-md border border-stone-200 border-stone-200 bg-transparent px-3 py-2 text-sm ring-offset-white placeholder:text-stone-500 focus:outline-none focus:ring-2 focus:ring-stone-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-stone-800 dark:border-stone-800 dark:ring-offset-stone-950 dark:placeholder:text-stone-400 dark:focus:ring-stone-800",
23
  className
24
  )}
25
  {...props}
@@ -68,7 +68,7 @@ const SelectLabel = React.forwardRef<
68
  >(({ className, ...props }, ref) => (
69
  <SelectPrimitive.Label
70
  ref={ref}
71
- className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
72
  {...props}
73
  />
74
  ))
@@ -81,7 +81,7 @@ const SelectItem = React.forwardRef<
81
  <SelectPrimitive.Item
82
  ref={ref}
83
  className={cn(
84
- "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-stone-100 focus:text-stone-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-stone-800 dark:focus:text-stone-50",
85
  className
86
  )}
87
  {...props}
 
19
  <SelectPrimitive.Trigger
20
  ref={ref}
21
  className={cn(
22
+ "flex h-10 w-full items-center justify-between rounded-md border border-stone-200 border-stone-200 bg-transparent px-3 py-2 text-base ring-offset-white placeholder:text-stone-500 focus:outline-none focus:ring-2 focus:ring-stone-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-stone-800 dark:border-stone-800 dark:ring-offset-stone-950 dark:placeholder:text-stone-400 dark:focus:ring-stone-800",
23
  className
24
  )}
25
  {...props}
 
68
  >(({ className, ...props }, ref) => (
69
  <SelectPrimitive.Label
70
  ref={ref}
71
+ className={cn("py-1.5 pl-8 pr-2 text-base font-semibold", className)}
72
  {...props}
73
  />
74
  ))
 
81
  <SelectPrimitive.Item
82
  ref={ref}
83
  className={cn(
84
+ "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-base outline-none focus:bg-stone-100 focus:text-stone-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-stone-800 dark:focus:text-stone-50",
85
  className
86
  )}
87
  {...props}
src/lib/usePageOrientation.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect } from "react";
2
+
3
+ /**
4
+ * This will turn the page in portrait or landscape depending on the number of pages
5
+ */
6
+ const usePageOrientation = () => {
7
+ useEffect(() => {
8
+ const updatePageOrientation = () => {
9
+ const pages = document.querySelectorAll(".comic-page");
10
+ const styleEl = document.createElement("style");
11
+
12
+ // Append style element to the head
13
+ document.head.appendChild(styleEl);
14
+
15
+ // Get the style sheet created in the above step
16
+ const styleSheet = styleEl.sheet as CSSStyleSheet;
17
+
18
+ if (pages.length >= 2) {
19
+ styleSheet.insertRule("@page { size: landscape }", 0);
20
+ } else {
21
+ styleSheet.insertRule("@page { size: portrait }", 0);
22
+ }
23
+ };
24
+
25
+ // Execute when the DOM is fully loaded
26
+ updatePageOrientation();
27
+
28
+ // Also execute when the window is resized
29
+ window.addEventListener("resize", updatePageOrientation);
30
+
31
+ // Clean up event listener on unmount
32
+ return () => {
33
+ window.removeEventListener("resize", updatePageOrientation);
34
+ };
35
+ }, []); // Empty dependency array ensures this runs once on mount and cleanup on unmount
36
+ };
37
+
38
+ export default usePageOrientation;
src/types.ts CHANGED
@@ -174,6 +174,7 @@ export type Settings = {
174
  groqApiKey: string
175
  groqApiLanguageModel: string
176
  hasGeneratedAtLeastOnce: boolean
 
177
  }
178
 
179
  export type DynamicConfig = {
 
174
  groqApiKey: string
175
  groqApiLanguageModel: string
176
  hasGeneratedAtLeastOnce: boolean
177
+ userDefinedMaxNumberOfPages: number
178
  }
179
 
180
  export type DynamicConfig = {
tailwind.config.js CHANGED
@@ -17,6 +17,16 @@ module.exports = {
17
  },
18
  },
19
  extend: {
 
 
 
 
 
 
 
 
 
 
20
  fontFamily: {
21
  indieflower: ['var(--font-indieflower)'],
22
  thegirlnextdoor: ['var(--font-the-girl-next-door)'],
 
17
  },
18
  },
19
  extend: {
20
+ spacing: {
21
+ 17: '4.25rem', // 68px
22
+ 18: '4.5rem', // 72px
23
+ 19: '4.75rem', // 76px
24
+ 20: '5rem', // 80px
25
+ 21: '5.25rem', // 84px
26
+ 22: '5.5rem', // 88px
27
+ 22: '5.5rem', // 88px
28
+ 26: '6.5rem', // 104px
29
+ },
30
  fontFamily: {
31
  indieflower: ['var(--font-indieflower)'],
32
  thegirlnextdoor: ['var(--font-the-girl-next-door)'],