Commit
•
f4dea7d
1
Parent(s):
a79c634
add LLM-generated story
Browse files- src/app/interface/top-menu/index.tsx +3 -2
- src/app/main.tsx +56 -18
- src/app/queries/getStory.ts +74 -0
- src/app/queries/{getBackground.ts → getStyle.ts} +15 -19
- src/app/queries/predict.ts +1 -1
- src/app/store/index.ts +4 -4
- src/components/ui/toast.tsx +127 -0
- src/components/ui/toaster.tsx +35 -0
- src/components/ui/use-toast.ts +192 -0
- src/lib/fonts.ts +7 -7
src/app/interface/top-menu/index.tsx
CHANGED
@@ -1,5 +1,7 @@
|
|
1 |
"use client"
|
2 |
|
|
|
|
|
3 |
import {
|
4 |
Select,
|
5 |
SelectContent,
|
@@ -12,7 +14,6 @@ import { cn } from "@/lib/utils"
|
|
12 |
import { FontName, fontList, fonts } from "@/lib/fonts"
|
13 |
import { Input } from "@/components/ui/input"
|
14 |
import { defaultPreset, getPreset, presets } from "@/app/engine/presets"
|
15 |
-
import { useState } from "react"
|
16 |
import { useStore } from "@/app/store"
|
17 |
|
18 |
export function TopMenu() {
|
@@ -119,7 +120,7 @@ export function TopMenu() {
|
|
119 |
)}>
|
120 |
<Label className="flex text-sm w-24">Font:</Label>
|
121 |
<Select
|
122 |
-
defaultValue={fontList.includes(preset.font) ? preset.font : "
|
123 |
onValueChange={(value) => { setFont(value as FontName) }}
|
124 |
// disabled={atLeastOnePanelIsBusy}
|
125 |
disabled={true}
|
|
|
1 |
"use client"
|
2 |
|
3 |
+
import { useState } from "react"
|
4 |
+
|
5 |
import {
|
6 |
Select,
|
7 |
SelectContent,
|
|
|
14 |
import { FontName, fontList, fonts } from "@/lib/fonts"
|
15 |
import { Input } from "@/components/ui/input"
|
16 |
import { defaultPreset, getPreset, presets } from "@/app/engine/presets"
|
|
|
17 |
import { useStore } from "@/app/store"
|
18 |
|
19 |
export function TopMenu() {
|
|
|
120 |
)}>
|
121 |
<Label className="flex text-sm w-24">Font:</Label>
|
122 |
<Select
|
123 |
+
defaultValue={fontList.includes(preset.font) ? preset.font : "actionman"}
|
124 |
onValueChange={(value) => { setFont(value as FontName) }}
|
125 |
// disabled={atLeastOnePanelIsBusy}
|
126 |
disabled={true}
|
src/app/main.tsx
CHANGED
@@ -7,10 +7,11 @@ import { PresetName, defaultPreset, getPreset } from "@/app/engine/presets"
|
|
7 |
|
8 |
import { cn } from "@/lib/utils"
|
9 |
import { TopMenu } from "./interface/top-menu"
|
10 |
-
import { FontName, defaultFont } from "@/lib/fonts"
|
11 |
import { getRandomLayoutName, layouts } from "./layouts"
|
12 |
import { useStore } from "./store"
|
13 |
import { Zoom } from "./interface/zoom"
|
|
|
14 |
|
15 |
export default function Main() {
|
16 |
const [_isPending, startTransition] = useTransition()
|
@@ -20,6 +21,9 @@ export default function Main() {
|
|
20 |
const requestedFont = (searchParams.get('font') as FontName) || defaultFont
|
21 |
const requestedPrompt = (searchParams.get('prompt') as string) || ""
|
22 |
|
|
|
|
|
|
|
23 |
const font = useStore(state => state.font)
|
24 |
const setFont = useStore(state => state.setFont)
|
25 |
|
@@ -53,22 +57,37 @@ export default function Main() {
|
|
53 |
useEffect(() => {
|
54 |
if (!prompt) { return }
|
55 |
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
72 |
}, [prompt, preset?.label]) // important: we need to react to preset changes too
|
73 |
|
74 |
const LayoutElement = (layouts as any)[layout]
|
@@ -78,7 +97,9 @@ export default function Main() {
|
|
78 |
<TopMenu />
|
79 |
<div className={cn(
|
80 |
`flex items-start w-screen h-screen pt-[120px] px-16 md:pt-[72px] overflow-y-scroll`,
|
81 |
-
`transition-all duration-200 ease-in-out
|
|
|
|
|
82 |
)}>
|
83 |
<div className="flex flex-col items-center w-full">
|
84 |
<div
|
@@ -105,6 +126,23 @@ export default function Main() {
|
|
105 |
</div>
|
106 |
</div>
|
107 |
<Zoom />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
108 |
</div>
|
109 |
)
|
110 |
}
|
|
|
7 |
|
8 |
import { cn } from "@/lib/utils"
|
9 |
import { TopMenu } from "./interface/top-menu"
|
10 |
+
import { FontName, defaultFont, fontList, fonts } from "@/lib/fonts"
|
11 |
import { getRandomLayoutName, layouts } from "./layouts"
|
12 |
import { useStore } from "./store"
|
13 |
import { Zoom } from "./interface/zoom"
|
14 |
+
import { getStory } from "./queries/getStory"
|
15 |
|
16 |
export default function Main() {
|
17 |
const [_isPending, startTransition] = useTransition()
|
|
|
21 |
const requestedFont = (searchParams.get('font') as FontName) || defaultFont
|
22 |
const requestedPrompt = (searchParams.get('prompt') as string) || ""
|
23 |
|
24 |
+
const isGeneratingStory = useStore(state => state.isGeneratingStory)
|
25 |
+
const setGeneratingStory = useStore(state => state.setGeneratingStory)
|
26 |
+
|
27 |
const font = useStore(state => state.font)
|
28 |
const setFont = useStore(state => state.setFont)
|
29 |
|
|
|
57 |
useEffect(() => {
|
58 |
if (!prompt) { return }
|
59 |
|
60 |
+
startTransition(async () => {
|
61 |
+
|
62 |
+
setGeneratingStory(true)
|
63 |
+
|
64 |
+
const newLayout = getRandomLayoutName()
|
65 |
+
console.log("using layout " + newLayout)
|
66 |
+
setLayout(newLayout)
|
67 |
+
|
68 |
+
try {
|
69 |
+
const llmResponse = await getStory({ preset, prompt })
|
70 |
+
console.log("response:", llmResponse)
|
71 |
+
|
72 |
+
// TODO call the LLM here!
|
73 |
+
const panelPromptPrefix = preset.imagePrompt(prompt).join(", ")
|
74 |
+
console.log("panel prompt prefix:", panelPromptPrefix)
|
75 |
+
|
76 |
+
const nbPanels = 4
|
77 |
+
const newPanels: string[] = []
|
78 |
+
|
79 |
+
for (let p = 0; p < nbPanels; p++) {
|
80 |
+
const newPanel = [panelPromptPrefix, llmResponse[p] || ""]
|
81 |
+
newPanels.push(newPanel.map(chunk => chunk).join(", "))
|
82 |
+
}
|
83 |
+
console.log("newPanels:", newPanels)
|
84 |
+
setPanels(newPanels)
|
85 |
+
} catch (err) {
|
86 |
+
console.error(err)
|
87 |
+
} finally {
|
88 |
+
setGeneratingStory(false)
|
89 |
+
}
|
90 |
+
})
|
91 |
}, [prompt, preset?.label]) // important: we need to react to preset changes too
|
92 |
|
93 |
const LayoutElement = (layouts as any)[layout]
|
|
|
97 |
<TopMenu />
|
98 |
<div className={cn(
|
99 |
`flex items-start w-screen h-screen pt-[120px] px-16 md:pt-[72px] overflow-y-scroll`,
|
100 |
+
`transition-all duration-200 ease-in-out`,
|
101 |
+
|
102 |
+
fonts.actionman.className
|
103 |
)}>
|
104 |
<div className="flex flex-col items-center w-full">
|
105 |
<div
|
|
|
126 |
</div>
|
127 |
</div>
|
128 |
<Zoom />
|
129 |
+
<div className={cn(
|
130 |
+
`z-20 fixed inset-0`,
|
131 |
+
`flex flex-row items-center justify-center`,
|
132 |
+
`transition-all duration-300 ease-in-out`,
|
133 |
+
isGeneratingStory
|
134 |
+
? `bg-zinc-100/10 backdrop-blur-md`
|
135 |
+
: `bg-zinc-100/0 backdrop-blur-none pointer-events-none`,
|
136 |
+
fonts.actionman.className
|
137 |
+
)}>
|
138 |
+
<div className={cn(
|
139 |
+
`text-center text-lg text-stone-600 w-[70%]`,
|
140 |
+
isGeneratingStory ? ``: `scale-0 opacity-0`,
|
141 |
+
`transition-all duration-300 ease-in-out`,
|
142 |
+
)}>
|
143 |
+
Generating your story.. (hold tight)
|
144 |
+
</div>
|
145 |
+
</div>
|
146 |
</div>
|
147 |
)
|
148 |
}
|
src/app/queries/getStory.ts
ADDED
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { createLlamaPrompt } from "@/lib/createLlamaPrompt"
|
2 |
+
|
3 |
+
import { predict } from "./predict"
|
4 |
+
import { Preset } from "../engine/presets"
|
5 |
+
|
6 |
+
type LLMResponse = Array<{panel: number; caption: string }>
|
7 |
+
|
8 |
+
export const getStory = async ({
|
9 |
+
preset,
|
10 |
+
prompt = "",
|
11 |
+
}: {
|
12 |
+
preset: Preset;
|
13 |
+
prompt: string;
|
14 |
+
}): Promise<string[]> => {
|
15 |
+
|
16 |
+
const query = createLlamaPrompt([
|
17 |
+
{
|
18 |
+
role: "system",
|
19 |
+
content: [
|
20 |
+
`You are a comic book author specialized in ${preset.llmPrompt}`,
|
21 |
+
`Please generate detailed drawing instructions for the 4 panels of a new silent comic book page.`,
|
22 |
+
`Give your response as a JSON array like this: \`Array<{ panel: number; caption: string}>\`.`,
|
23 |
+
// `Give your response as Markdown bullet points.`,
|
24 |
+
`Be brief in your caption don't add your own comments. Be straight to the point, and never reply things like "Sure, I can.." etc.`
|
25 |
+
].filter(item => item).join("\n")
|
26 |
+
},
|
27 |
+
{
|
28 |
+
role: "user",
|
29 |
+
content: `The story is: ${prompt}`,
|
30 |
+
}
|
31 |
+
])
|
32 |
+
|
33 |
+
|
34 |
+
let result = ""
|
35 |
+
try {
|
36 |
+
result = await predict(query)
|
37 |
+
if (!result.trim().length) {
|
38 |
+
throw new Error("empty result!")
|
39 |
+
}
|
40 |
+
} catch (err) {
|
41 |
+
console.log(`prediction of the story failed, trying again..`)
|
42 |
+
try {
|
43 |
+
result = await predict(query+".")
|
44 |
+
if (!result.trim().length) {
|
45 |
+
throw new Error("empty result!")
|
46 |
+
}
|
47 |
+
} catch (err) {
|
48 |
+
console.error(`prediction of the story failed again!`)
|
49 |
+
throw new Error(`failed to generate the story ${err}`)
|
50 |
+
}
|
51 |
+
}
|
52 |
+
|
53 |
+
console.log("Raw response from LLM:", result)
|
54 |
+
let tmp = result // result.split("Caption:").pop() || result
|
55 |
+
tmp = tmp
|
56 |
+
.replaceAll("}}", "}")
|
57 |
+
.replaceAll("]]", "]")
|
58 |
+
.replaceAll(",,", ",")
|
59 |
+
|
60 |
+
try {
|
61 |
+
// we only keep what's after the first [
|
62 |
+
let jsonOrNot = `[${tmp.split("[").pop() || ""}`
|
63 |
+
|
64 |
+
// and before the first ]
|
65 |
+
jsonOrNot = `${jsonOrNot.split("]").shift() || ""}]`
|
66 |
+
|
67 |
+
const jsonData = JSON.parse(jsonOrNot) as LLMResponse
|
68 |
+
const captions = jsonData.map(item => item.caption.trim())
|
69 |
+
return captions.map(caption => caption.split(":").pop()?.trim() || "")
|
70 |
+
} catch (err) {
|
71 |
+
console.log(`failed to read LLM response: ${err}`)
|
72 |
+
return []
|
73 |
+
}
|
74 |
+
}
|
src/app/queries/{getBackground.ts → getStyle.ts}
RENAMED
@@ -3,54 +3,50 @@ import { createLlamaPrompt } from "@/lib/createLlamaPrompt"
|
|
3 |
import { predict } from "./predict"
|
4 |
import { Preset } from "../engine/presets"
|
5 |
|
6 |
-
export const
|
7 |
preset,
|
8 |
-
|
9 |
-
previousPanelPrompt = "",
|
10 |
-
newPanelPrompt = "",
|
11 |
}: {
|
12 |
preset: Preset;
|
13 |
-
|
14 |
-
previousPanelPrompt: string;
|
15 |
-
newPanelPrompt: string;
|
16 |
}) => {
|
17 |
|
18 |
-
const
|
19 |
{
|
20 |
role: "system",
|
21 |
content: [
|
22 |
-
`You are a comic book author
|
23 |
-
`
|
24 |
-
`
|
25 |
-
`Be brief in your caption don't add your own comments. Be straight to the point, and never reply things like "
|
26 |
].filter(item => item).join("\n")
|
27 |
},
|
28 |
{
|
29 |
role: "user",
|
30 |
-
content:
|
31 |
}
|
32 |
])
|
33 |
|
34 |
|
35 |
let result = ""
|
36 |
try {
|
37 |
-
result = await predict(
|
38 |
if (!result.trim().length) {
|
39 |
throw new Error("empty result!")
|
40 |
}
|
41 |
} catch (err) {
|
42 |
-
console.log(`prediction of the
|
43 |
try {
|
44 |
-
result = await predict(
|
45 |
if (!result.trim().length) {
|
46 |
throw new Error("empty result!")
|
47 |
}
|
48 |
} catch (err) {
|
49 |
-
console.error(`prediction of the
|
50 |
-
throw new Error(`failed to generate the
|
51 |
}
|
52 |
}
|
53 |
|
54 |
-
const tmp = result.split("Caption:").pop() || result
|
55 |
return tmp.replaceAll("\n", ", ")
|
56 |
}
|
|
|
3 |
import { predict } from "./predict"
|
4 |
import { Preset } from "../engine/presets"
|
5 |
|
6 |
+
export const getStory = async ({
|
7 |
preset,
|
8 |
+
prompt = "",
|
|
|
|
|
9 |
}: {
|
10 |
preset: Preset;
|
11 |
+
prompt: string;
|
|
|
|
|
12 |
}) => {
|
13 |
|
14 |
+
const query = createLlamaPrompt([
|
15 |
{
|
16 |
role: "system",
|
17 |
content: [
|
18 |
+
`You are a comic book author specialized in ${preset.llmPrompt}`,
|
19 |
+
`You are going to be asked to write a comic book page, your mission is to answer a JSON array containing 4 items, to describe the page (one item per panel).`,
|
20 |
+
`Each array item should be a comic book panel caption the describe the environment, era, characters, objects, textures, lighting.`,
|
21 |
+
`Be brief in your caption don't add your own comments. Be straight to the point, and never reply things like "Sure, I can.." etc.`
|
22 |
].filter(item => item).join("\n")
|
23 |
},
|
24 |
{
|
25 |
role: "user",
|
26 |
+
content: `The story is: ${prompt}`,
|
27 |
}
|
28 |
])
|
29 |
|
30 |
|
31 |
let result = ""
|
32 |
try {
|
33 |
+
result = await predict(query)
|
34 |
if (!result.trim().length) {
|
35 |
throw new Error("empty result!")
|
36 |
}
|
37 |
} catch (err) {
|
38 |
+
console.log(`prediction of the story failed, trying again..`)
|
39 |
try {
|
40 |
+
result = await predict(query+".")
|
41 |
if (!result.trim().length) {
|
42 |
throw new Error("empty result!")
|
43 |
}
|
44 |
} catch (err) {
|
45 |
+
console.error(`prediction of the story failed again!`)
|
46 |
+
throw new Error(`failed to generate the story ${err}`)
|
47 |
}
|
48 |
}
|
49 |
|
50 |
+
const tmp = result // result.split("Caption:").pop() || result
|
51 |
return tmp.replaceAll("\n", ", ")
|
52 |
}
|
src/app/queries/predict.ts
CHANGED
@@ -17,7 +17,7 @@ export async function predict(inputs: string) {
|
|
17 |
do_sample: true,
|
18 |
|
19 |
// hard limit for max_new_tokens is 1512
|
20 |
-
max_new_tokens:
|
21 |
return_full_text: false,
|
22 |
}
|
23 |
})) {
|
|
|
17 |
do_sample: true,
|
18 |
|
19 |
// hard limit for max_new_tokens is 1512
|
20 |
+
max_new_tokens: 300, // 1150,
|
21 |
return_full_text: false,
|
22 |
}
|
23 |
})) {
|
src/app/store/index.ts
CHANGED
@@ -14,7 +14,7 @@ export const useStore = create<{
|
|
14 |
captions: Record<string, string>
|
15 |
layout: LayoutName
|
16 |
zoomLevel: number
|
17 |
-
|
18 |
panelGenerationStatus: Record<number, boolean>
|
19 |
isGeneratingText: boolean
|
20 |
atLeastOnePanelIsBusy: boolean
|
@@ -25,7 +25,7 @@ export const useStore = create<{
|
|
25 |
setLayout: (layout: LayoutName) => void
|
26 |
setCaption: (panelId: number, caption: string) => void
|
27 |
setZoomLevel: (zoomLevel: number) => void
|
28 |
-
|
29 |
setGeneratingImages: (panelId: number, value: boolean) => void
|
30 |
setGeneratingText: (isGeneratingText: boolean) => void
|
31 |
}>((set, get) => ({
|
@@ -36,7 +36,7 @@ export const useStore = create<{
|
|
36 |
captions: {},
|
37 |
layout: getRandomLayoutName(),
|
38 |
zoomLevel: 50,
|
39 |
-
|
40 |
panelGenerationStatus: {},
|
41 |
isGeneratingText: false,
|
42 |
atLeastOnePanelIsBusy: false,
|
@@ -81,7 +81,7 @@ export const useStore = create<{
|
|
81 |
},
|
82 |
setLayout: (layout: LayoutName) => set({ layout }),
|
83 |
setZoomLevel: (zoomLevel: number) => set({ zoomLevel }),
|
84 |
-
|
85 |
setGeneratingImages: (panelId: number, value: boolean) => {
|
86 |
|
87 |
const panelGenerationStatus: Record<number, boolean> = {
|
|
|
14 |
captions: Record<string, string>
|
15 |
layout: LayoutName
|
16 |
zoomLevel: number
|
17 |
+
isGeneratingStory: boolean
|
18 |
panelGenerationStatus: Record<number, boolean>
|
19 |
isGeneratingText: boolean
|
20 |
atLeastOnePanelIsBusy: boolean
|
|
|
25 |
setLayout: (layout: LayoutName) => void
|
26 |
setCaption: (panelId: number, caption: string) => void
|
27 |
setZoomLevel: (zoomLevel: number) => void
|
28 |
+
setGeneratingStory: (isGeneratingStory: boolean) => void
|
29 |
setGeneratingImages: (panelId: number, value: boolean) => void
|
30 |
setGeneratingText: (isGeneratingText: boolean) => void
|
31 |
}>((set, get) => ({
|
|
|
36 |
captions: {},
|
37 |
layout: getRandomLayoutName(),
|
38 |
zoomLevel: 50,
|
39 |
+
isGeneratingStory: false,
|
40 |
panelGenerationStatus: {},
|
41 |
isGeneratingText: false,
|
42 |
atLeastOnePanelIsBusy: false,
|
|
|
81 |
},
|
82 |
setLayout: (layout: LayoutName) => set({ layout }),
|
83 |
setZoomLevel: (zoomLevel: number) => set({ zoomLevel }),
|
84 |
+
setGeneratingStory: (isGeneratingStory: boolean) => set({ isGeneratingStory }),
|
85 |
setGeneratingImages: (panelId: number, value: boolean) => {
|
86 |
|
87 |
const panelGenerationStatus: Record<number, boolean> = {
|
src/components/ui/toast.tsx
ADDED
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import * as ToastPrimitives from "@radix-ui/react-toast"
|
3 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
4 |
+
import { X } from "lucide-react"
|
5 |
+
|
6 |
+
import { cn } from "@/lib/utils"
|
7 |
+
|
8 |
+
const ToastProvider = ToastPrimitives.Provider
|
9 |
+
|
10 |
+
const ToastViewport = React.forwardRef<
|
11 |
+
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
12 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
13 |
+
>(({ className, ...props }, ref) => (
|
14 |
+
<ToastPrimitives.Viewport
|
15 |
+
ref={ref}
|
16 |
+
className={cn(
|
17 |
+
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
18 |
+
className
|
19 |
+
)}
|
20 |
+
{...props}
|
21 |
+
/>
|
22 |
+
))
|
23 |
+
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
24 |
+
|
25 |
+
const toastVariants = cva(
|
26 |
+
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border border-stone-200 p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-stone-800",
|
27 |
+
{
|
28 |
+
variants: {
|
29 |
+
variant: {
|
30 |
+
default: "border bg-white text-stone-950 dark:bg-stone-950 dark:text-stone-50",
|
31 |
+
destructive:
|
32 |
+
"destructive group border-red-500 bg-red-500 text-stone-50 dark:border-red-900 dark:bg-red-900 dark:text-stone-50",
|
33 |
+
},
|
34 |
+
},
|
35 |
+
defaultVariants: {
|
36 |
+
variant: "default",
|
37 |
+
},
|
38 |
+
}
|
39 |
+
)
|
40 |
+
|
41 |
+
const Toast = React.forwardRef<
|
42 |
+
React.ElementRef<typeof ToastPrimitives.Root>,
|
43 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
44 |
+
VariantProps<typeof toastVariants>
|
45 |
+
>(({ className, variant, ...props }, ref) => {
|
46 |
+
return (
|
47 |
+
<ToastPrimitives.Root
|
48 |
+
ref={ref}
|
49 |
+
className={cn(toastVariants({ variant }), className)}
|
50 |
+
{...props}
|
51 |
+
/>
|
52 |
+
)
|
53 |
+
})
|
54 |
+
Toast.displayName = ToastPrimitives.Root.displayName
|
55 |
+
|
56 |
+
const ToastAction = React.forwardRef<
|
57 |
+
React.ElementRef<typeof ToastPrimitives.Action>,
|
58 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
59 |
+
>(({ className, ...props }, ref) => (
|
60 |
+
<ToastPrimitives.Action
|
61 |
+
ref={ref}
|
62 |
+
className={cn(
|
63 |
+
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-stone-200 bg-transparent px-3 text-sm font-medium ring-offset-white transition-colors hover:bg-stone-100 focus:outline-none focus:ring-2 focus:ring-stone-950 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-stone-100/40 group-[.destructive]:hover:border-red-500/30 group-[.destructive]:hover:bg-red-500 group-[.destructive]:hover:text-stone-50 group-[.destructive]:focus:ring-red-500 dark:border-stone-800 dark:ring-offset-stone-950 dark:hover:bg-stone-800 dark:focus:ring-stone-300 dark:group-[.destructive]:border-stone-800/40 dark:group-[.destructive]:hover:border-red-900/30 dark:group-[.destructive]:hover:bg-red-900 dark:group-[.destructive]:hover:text-stone-50 dark:group-[.destructive]:focus:ring-red-900",
|
64 |
+
className
|
65 |
+
)}
|
66 |
+
{...props}
|
67 |
+
/>
|
68 |
+
))
|
69 |
+
ToastAction.displayName = ToastPrimitives.Action.displayName
|
70 |
+
|
71 |
+
const ToastClose = React.forwardRef<
|
72 |
+
React.ElementRef<typeof ToastPrimitives.Close>,
|
73 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
74 |
+
>(({ className, ...props }, ref) => (
|
75 |
+
<ToastPrimitives.Close
|
76 |
+
ref={ref}
|
77 |
+
className={cn(
|
78 |
+
"absolute right-2 top-2 rounded-md p-1 text-stone-950/50 opacity-0 transition-opacity hover:text-stone-950 focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:text-stone-50/50 dark:hover:text-stone-50",
|
79 |
+
className
|
80 |
+
)}
|
81 |
+
toast-close=""
|
82 |
+
{...props}
|
83 |
+
>
|
84 |
+
<X className="h-4 w-4" />
|
85 |
+
</ToastPrimitives.Close>
|
86 |
+
))
|
87 |
+
ToastClose.displayName = ToastPrimitives.Close.displayName
|
88 |
+
|
89 |
+
const ToastTitle = React.forwardRef<
|
90 |
+
React.ElementRef<typeof ToastPrimitives.Title>,
|
91 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
92 |
+
>(({ className, ...props }, ref) => (
|
93 |
+
<ToastPrimitives.Title
|
94 |
+
ref={ref}
|
95 |
+
className={cn("text-sm font-semibold", className)}
|
96 |
+
{...props}
|
97 |
+
/>
|
98 |
+
))
|
99 |
+
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
100 |
+
|
101 |
+
const ToastDescription = React.forwardRef<
|
102 |
+
React.ElementRef<typeof ToastPrimitives.Description>,
|
103 |
+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
104 |
+
>(({ className, ...props }, ref) => (
|
105 |
+
<ToastPrimitives.Description
|
106 |
+
ref={ref}
|
107 |
+
className={cn("text-sm opacity-90", className)}
|
108 |
+
{...props}
|
109 |
+
/>
|
110 |
+
))
|
111 |
+
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
112 |
+
|
113 |
+
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
114 |
+
|
115 |
+
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
116 |
+
|
117 |
+
export {
|
118 |
+
type ToastProps,
|
119 |
+
type ToastActionElement,
|
120 |
+
ToastProvider,
|
121 |
+
ToastViewport,
|
122 |
+
Toast,
|
123 |
+
ToastTitle,
|
124 |
+
ToastDescription,
|
125 |
+
ToastClose,
|
126 |
+
ToastAction,
|
127 |
+
}
|
src/components/ui/toaster.tsx
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import {
|
4 |
+
Toast,
|
5 |
+
ToastClose,
|
6 |
+
ToastDescription,
|
7 |
+
ToastProvider,
|
8 |
+
ToastTitle,
|
9 |
+
ToastViewport,
|
10 |
+
} from "@/components/ui/toast"
|
11 |
+
import { useToast } from "@/components/ui/use-toast"
|
12 |
+
|
13 |
+
export function Toaster() {
|
14 |
+
const { toasts } = useToast()
|
15 |
+
|
16 |
+
return (
|
17 |
+
<ToastProvider>
|
18 |
+
{toasts.map(function ({ id, title, description, action, ...props }) {
|
19 |
+
return (
|
20 |
+
<Toast key={id} {...props}>
|
21 |
+
<div className="grid gap-1">
|
22 |
+
{title && <ToastTitle>{title}</ToastTitle>}
|
23 |
+
{description && (
|
24 |
+
<ToastDescription>{description}</ToastDescription>
|
25 |
+
)}
|
26 |
+
</div>
|
27 |
+
{action}
|
28 |
+
<ToastClose />
|
29 |
+
</Toast>
|
30 |
+
)
|
31 |
+
})}
|
32 |
+
<ToastViewport />
|
33 |
+
</ToastProvider>
|
34 |
+
)
|
35 |
+
}
|
src/components/ui/use-toast.ts
ADDED
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Inspired by react-hot-toast library
|
2 |
+
import * as React from "react"
|
3 |
+
|
4 |
+
import type {
|
5 |
+
ToastActionElement,
|
6 |
+
ToastProps,
|
7 |
+
} from "@/components/ui/toast"
|
8 |
+
|
9 |
+
const TOAST_LIMIT = 1
|
10 |
+
const TOAST_REMOVE_DELAY = 1000000
|
11 |
+
|
12 |
+
type ToasterToast = ToastProps & {
|
13 |
+
id: string
|
14 |
+
title?: React.ReactNode
|
15 |
+
description?: React.ReactNode
|
16 |
+
action?: ToastActionElement
|
17 |
+
}
|
18 |
+
|
19 |
+
const actionTypes = {
|
20 |
+
ADD_TOAST: "ADD_TOAST",
|
21 |
+
UPDATE_TOAST: "UPDATE_TOAST",
|
22 |
+
DISMISS_TOAST: "DISMISS_TOAST",
|
23 |
+
REMOVE_TOAST: "REMOVE_TOAST",
|
24 |
+
} as const
|
25 |
+
|
26 |
+
let count = 0
|
27 |
+
|
28 |
+
function genId() {
|
29 |
+
count = (count + 1) % Number.MAX_VALUE
|
30 |
+
return count.toString()
|
31 |
+
}
|
32 |
+
|
33 |
+
type ActionType = typeof actionTypes
|
34 |
+
|
35 |
+
type Action =
|
36 |
+
| {
|
37 |
+
type: ActionType["ADD_TOAST"]
|
38 |
+
toast: ToasterToast
|
39 |
+
}
|
40 |
+
| {
|
41 |
+
type: ActionType["UPDATE_TOAST"]
|
42 |
+
toast: Partial<ToasterToast>
|
43 |
+
}
|
44 |
+
| {
|
45 |
+
type: ActionType["DISMISS_TOAST"]
|
46 |
+
toastId?: ToasterToast["id"]
|
47 |
+
}
|
48 |
+
| {
|
49 |
+
type: ActionType["REMOVE_TOAST"]
|
50 |
+
toastId?: ToasterToast["id"]
|
51 |
+
}
|
52 |
+
|
53 |
+
interface State {
|
54 |
+
toasts: ToasterToast[]
|
55 |
+
}
|
56 |
+
|
57 |
+
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
58 |
+
|
59 |
+
const addToRemoveQueue = (toastId: string) => {
|
60 |
+
if (toastTimeouts.has(toastId)) {
|
61 |
+
return
|
62 |
+
}
|
63 |
+
|
64 |
+
const timeout = setTimeout(() => {
|
65 |
+
toastTimeouts.delete(toastId)
|
66 |
+
dispatch({
|
67 |
+
type: "REMOVE_TOAST",
|
68 |
+
toastId: toastId,
|
69 |
+
})
|
70 |
+
}, TOAST_REMOVE_DELAY)
|
71 |
+
|
72 |
+
toastTimeouts.set(toastId, timeout)
|
73 |
+
}
|
74 |
+
|
75 |
+
export const reducer = (state: State, action: Action): State => {
|
76 |
+
switch (action.type) {
|
77 |
+
case "ADD_TOAST":
|
78 |
+
return {
|
79 |
+
...state,
|
80 |
+
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
81 |
+
}
|
82 |
+
|
83 |
+
case "UPDATE_TOAST":
|
84 |
+
return {
|
85 |
+
...state,
|
86 |
+
toasts: state.toasts.map((t) =>
|
87 |
+
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
88 |
+
),
|
89 |
+
}
|
90 |
+
|
91 |
+
case "DISMISS_TOAST": {
|
92 |
+
const { toastId } = action
|
93 |
+
|
94 |
+
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
95 |
+
// but I'll keep it here for simplicity
|
96 |
+
if (toastId) {
|
97 |
+
addToRemoveQueue(toastId)
|
98 |
+
} else {
|
99 |
+
state.toasts.forEach((toast) => {
|
100 |
+
addToRemoveQueue(toast.id)
|
101 |
+
})
|
102 |
+
}
|
103 |
+
|
104 |
+
return {
|
105 |
+
...state,
|
106 |
+
toasts: state.toasts.map((t) =>
|
107 |
+
t.id === toastId || toastId === undefined
|
108 |
+
? {
|
109 |
+
...t,
|
110 |
+
open: false,
|
111 |
+
}
|
112 |
+
: t
|
113 |
+
),
|
114 |
+
}
|
115 |
+
}
|
116 |
+
case "REMOVE_TOAST":
|
117 |
+
if (action.toastId === undefined) {
|
118 |
+
return {
|
119 |
+
...state,
|
120 |
+
toasts: [],
|
121 |
+
}
|
122 |
+
}
|
123 |
+
return {
|
124 |
+
...state,
|
125 |
+
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
126 |
+
}
|
127 |
+
}
|
128 |
+
}
|
129 |
+
|
130 |
+
const listeners: Array<(state: State) => void> = []
|
131 |
+
|
132 |
+
let memoryState: State = { toasts: [] }
|
133 |
+
|
134 |
+
function dispatch(action: Action) {
|
135 |
+
memoryState = reducer(memoryState, action)
|
136 |
+
listeners.forEach((listener) => {
|
137 |
+
listener(memoryState)
|
138 |
+
})
|
139 |
+
}
|
140 |
+
|
141 |
+
type Toast = Omit<ToasterToast, "id">
|
142 |
+
|
143 |
+
function toast({ ...props }: Toast) {
|
144 |
+
const id = genId()
|
145 |
+
|
146 |
+
const update = (props: ToasterToast) =>
|
147 |
+
dispatch({
|
148 |
+
type: "UPDATE_TOAST",
|
149 |
+
toast: { ...props, id },
|
150 |
+
})
|
151 |
+
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
152 |
+
|
153 |
+
dispatch({
|
154 |
+
type: "ADD_TOAST",
|
155 |
+
toast: {
|
156 |
+
...props,
|
157 |
+
id,
|
158 |
+
open: true,
|
159 |
+
onOpenChange: (open) => {
|
160 |
+
if (!open) dismiss()
|
161 |
+
},
|
162 |
+
},
|
163 |
+
})
|
164 |
+
|
165 |
+
return {
|
166 |
+
id: id,
|
167 |
+
dismiss,
|
168 |
+
update,
|
169 |
+
}
|
170 |
+
}
|
171 |
+
|
172 |
+
function useToast() {
|
173 |
+
const [state, setState] = React.useState<State>(memoryState)
|
174 |
+
|
175 |
+
React.useEffect(() => {
|
176 |
+
listeners.push(setState)
|
177 |
+
return () => {
|
178 |
+
const index = listeners.indexOf(setState)
|
179 |
+
if (index > -1) {
|
180 |
+
listeners.splice(index, 1)
|
181 |
+
}
|
182 |
+
}
|
183 |
+
}, [state])
|
184 |
+
|
185 |
+
return {
|
186 |
+
...state,
|
187 |
+
toast,
|
188 |
+
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
189 |
+
}
|
190 |
+
}
|
191 |
+
|
192 |
+
export { useToast, toast }
|
src/lib/fonts.ts
CHANGED
@@ -78,16 +78,16 @@ export const digitalstrip = localFont({
|
|
78 |
export const fonts = {
|
79 |
indieflower,
|
80 |
thegirlnextdoor,
|
81 |
-
komika,
|
82 |
actionman,
|
83 |
karantula,
|
84 |
manoskope,
|
85 |
-
paeteround,
|
86 |
-
qarmic,
|
87 |
-
archrival,
|
88 |
-
cartoonist,
|
89 |
-
toontime,
|
90 |
-
vtc,
|
91 |
digitalstrip
|
92 |
}
|
93 |
|
|
|
78 |
export const fonts = {
|
79 |
indieflower,
|
80 |
thegirlnextdoor,
|
81 |
+
// komika,
|
82 |
actionman,
|
83 |
karantula,
|
84 |
manoskope,
|
85 |
+
// paeteround,
|
86 |
+
// qarmic,
|
87 |
+
// archrival,
|
88 |
+
// cartoonist,
|
89 |
+
// toontime,
|
90 |
+
// vtc,
|
91 |
digitalstrip
|
92 |
}
|
93 |
|