Skip to content

Commit 784d31e

Browse files
committed
feat: triggers
1 parent 13f2080 commit 784d31e

12 files changed

Lines changed: 298 additions & 74 deletions

File tree

bun.lockb

1.68 KB
Binary file not shown.

convex/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ export default defineSchema({
66
owner: v.optional(v.string()),
77
word: v.string(),
88
meaning: v.string(),
9+
trigger: v.string(),
910
examples: v.array(v.string()),
11+
dervation: v.optional(v.string()),
1012
}).index("by_word", ["word"]),
1113
metadata: defineTable({
1214
word_count: v.number(),

convex/words.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ export const createWord = mutation({
66
owner: v.string(),
77
word: v.string(),
88
meaning: v.string(),
9+
trigger: v.string(),
910
examples: v.array(v.string()),
1011
},
1112
handler: async (ctx, args) => {
1213
const id = await ctx.db.insert("words", {
1314
owner: args.owner,
1415
word: args.word,
1516
meaning: args.meaning,
17+
trigger: args.trigger,
1618
examples: args.examples,
1719
});
1820
return id;
@@ -44,13 +46,13 @@ export const lazyLoadWords = query({
4446

4547
if (args.selectedLetter) {
4648
allWords = allWords.filter(
47-
(w) => w.word.charAt(0).toUpperCase() === args.selectedLetter
49+
(w) => w.word.charAt(0).toUpperCase() === args.selectedLetter,
4850
);
4951
}
5052

5153
if (args.startsAfterId) {
5254
const startIndex = allWords.findIndex(
53-
(w) => w._id === args.startsAfterId
55+
(w) => w._id === args.startsAfterId,
5456
);
5557
if (startIndex !== -1) {
5658
return allWords.slice(startIndex + 1, startIndex + 1 + args.limit);
@@ -67,7 +69,7 @@ export const getWordByName = query({
6769
handler: async (ctx, args) => {
6870
const allWords = await ctx.db.query("words").collect();
6971
return allWords.find(
70-
(w) => w.word.toLowerCase() === args.word.toLowerCase()
72+
(w) => w.word.toLowerCase() === args.word.toLowerCase(),
7173
);
7274
},
7375
});
@@ -145,13 +147,13 @@ export const lazyLoadUserWords = query({
145147

146148
if (args.selectedLetter) {
147149
allWords = allWords.filter(
148-
(w) => w.word.charAt(0).toUpperCase() === args.selectedLetter
150+
(w) => w.word.charAt(0).toUpperCase() === args.selectedLetter,
149151
);
150152
}
151153

152154
if (args.startsAfterId) {
153155
const startIndex = allWords.findIndex(
154-
(w) => w._id === args.startsAfterId
156+
(w) => w._id === args.startsAfterId,
155157
);
156158
if (startIndex !== -1) {
157159
return allWords.slice(startIndex + 1, startIndex + 1 + args.limit);

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@
1111
},
1212
"dependencies": {
1313
"@ai-sdk/google": "^2.0.23",
14+
"@ai-sdk/groq": "^3.0.33",
1415
"@clerk/nextjs": "^6.34.0",
1516
"@radix-ui/react-slot": "^1.2.3",
1617
"@upstash/ratelimit": "^2.0.6",
1718
"@upstash/redis": "^1.35.6",
1819
"@vercel/analytics": "^1.5.0",
19-
"ai": "^5.0.76",
20+
"ai": "^6.0.146",
2021
"class-variance-authority": "^0.7.1",
2122
"clsx": "^2.1.1",
2223
"convex": "^1.28.0",

src/app/add-word/page.tsx

Lines changed: 104 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useEffect, useState, useRef } from "react";
1111
import WordCard from "@/components/WordCard";
1212
import { useUser } from "@clerk/nextjs";
1313
import SmoothFadeLayout from "@/components/SmoothFadePageTransition";
14+
import { greetings } from "../consts";
1415

1516
const notifySuccess = () => toast.success("Word added successfully!");
1617
const notifyError = (message: string) => toast.error(`${message}`);
@@ -27,31 +28,26 @@ export default function AddWord() {
2728

2829
const [word, setWord] = useState("");
2930
const [meaning, setMeaning] = useState("");
31+
const [trigger, setTrigger] = useState("");
3032
const [examples, setExamples] = useState<string[]>([]);
3133

3234
const [debouncedWord, setDebouncedWord] = useState(word);
3335
const [AIDebouncedWord, setAIDebouncedWord] = useState(word);
3436

3537
const [meaningExists, setMeaningExists] = useState(false);
3638
const [meaningLoading, setMeaningLoading] = useState(false);
39+
const [triggerLoading, setTriggerLoading] = useState(false);
3740
const [examplesLoading, setExamplesLoading] = useState(false);
3841
const [greeting, setGreeting] = useState("");
3942

40-
const greetings = [
41-
"Discovered a new word? Let's define it!",
42-
"Found a word no one knows? Add it here!",
43-
"Help us grow the dictionary! Submit your word.",
44-
"Got a rare word? Share its meaning!",
45-
"What did you come across?",
46-
];
47-
4843
useEffect(() => {
4944
const random = greetings[Math.floor(Math.random() * greetings.length)];
5045
setGreeting(random);
5146
}, []);
5247

5348
useEffect(() => {
5449
setMeaning("");
50+
setTrigger("");
5551
setExamples([]);
5652

5753
const convexSearch = setTimeout(() => {
@@ -80,7 +76,7 @@ export default function AddWord() {
8076

8177
if (res.status === 429)
8278
throw new Error(
83-
"Rate limit exceeded. Please wait a moment before trying again."
79+
"Rate limit exceeded. Please wait a moment before trying again.",
8480
);
8581
if (!res.ok) throw new Error("Failed to generate meaning");
8682
const data = await res.json();
@@ -92,6 +88,31 @@ export default function AddWord() {
9288
}
9389
};
9490

91+
const fetchTrigger = async () => {
92+
try {
93+
setTriggerLoading(true);
94+
const res = await fetch("/api/generate-trigger", {
95+
method: "POST",
96+
headers: {
97+
"Content-Type": "application/json",
98+
},
99+
body: JSON.stringify({ word: AIDebouncedWord }),
100+
});
101+
102+
if (res.status === 429)
103+
throw new Error(
104+
"Rate limit exceeded. Please wait a moment before trying again.",
105+
);
106+
if (!res.ok) throw new Error("Failed to generate trigger.");
107+
const data = await res.json();
108+
setTrigger(data.trigger);
109+
} catch (error) {
110+
notifyError((error as Error).message);
111+
} finally {
112+
setTriggerLoading(false);
113+
}
114+
};
115+
95116
const fetchExamples = async () => {
96117
try {
97118
setExamplesLoading(true);
@@ -105,7 +126,7 @@ export default function AddWord() {
105126

106127
if (res.status === 429)
107128
throw new Error(
108-
"Rate limit exceeded. Please wait a moment before trying again."
129+
"Rate limit exceeded. Please wait a moment before trying again.",
109130
);
110131
if (!res.ok) throw new Error("Failed to generate examples");
111132
const data = await res.json();
@@ -117,10 +138,48 @@ export default function AddWord() {
117138
}
118139
};
119140

141+
const fetchDerviation = async () => {
142+
try {
143+
const res = await fetch(
144+
`https://api.datamuse.com/words?sp=${encodeURIComponent(word.slice(0, 6))}*&md=p&max=100`,
145+
);
146+
if (!res.ok) throw new Error("Failed to fetch derivations");
147+
const data = await res.json();
148+
149+
const base = word.toLowerCase();
150+
const baseRoot = base.slice(0, 5);
151+
152+
// keep only clean derivations
153+
const derivations = data.filter((w) => {
154+
const wLower = w.word.toLowerCase();
155+
156+
const valid =
157+
wLower.startsWith(baseRoot) && // same root
158+
!wLower.includes(" ") && // ignore phrases
159+
wLower !== base && // not the same word
160+
!/[^a-zA-Z]/.test(wLower) && // remove words with weird chars
161+
(wLower.includes("ly") ||
162+
wLower.includes("ness") ||
163+
wLower.includes("est") ||
164+
wLower.includes("ate") ||
165+
wLower.includes("acy") ||
166+
wLower.includes("ous")); // common derivational suffixes
167+
168+
return valid;
169+
});
170+
171+
console.log(derivations);
172+
} catch (error) {
173+
notifyError((error as Error).message);
174+
}
175+
};
176+
120177
useEffect(() => {
121178
if (!AIDebouncedWord || meaningExists) return;
122179

180+
fetchDerviation();
123181
fetchMeaning();
182+
fetchTrigger();
124183
fetchExamples();
125184
}, [AIDebouncedWord]);
126185

@@ -137,7 +196,7 @@ export default function AddWord() {
137196

138197
const getWord = useQuery(
139198
api.words.getWordByName,
140-
debouncedWord ? { word: debouncedWord } : "skip"
199+
debouncedWord ? { word: debouncedWord } : "skip",
141200
);
142201

143202
const isNumeric = (n: string) => {
@@ -153,14 +212,15 @@ export default function AddWord() {
153212
if (alreadyExists) return notifyError("Word already exists");
154213
if (meaning.trim().length === 0 || examples.length === 0)
155214
return notifyError(
156-
"Sit Tight while AI generates the meaning and examples!"
215+
"Sit Tight while AI generates the meaning and examples!",
157216
);
158217
const capitalizedWord = word.charAt(0).toUpperCase() + word.slice(1);
159218
try {
160219
void createWord({
161220
owner: user?.id || "anonymous",
162221
word: capitalizedWord,
163222
meaning: meaning,
223+
trigger: trigger,
164224
examples: examples,
165225
});
166226
void updateCount();
@@ -218,6 +278,7 @@ export default function AddWord() {
218278
const handleRegenerateMeaning = async (type: string) => {
219279
try {
220280
if (type === "Meaning") await fetchMeaning();
281+
else if (type === "Trigger") await fetchTrigger();
221282
else await fetchExamples();
222283
} catch (err) {
223284
notifyError((err as Error).message);
@@ -283,6 +344,36 @@ export default function AddWord() {
283344
<p>Generating definition...</p>
284345
</div>
285346
)}
347+
348+
{trigger.length > 0 && (
349+
<section>
350+
<div className="mt-5 flex items-center justify-between">
351+
<label className="block font-medium text-sm text-gray-700 dark:text-gray-400">
352+
Trigger (generated by AI)
353+
</label>
354+
<Button
355+
type="button"
356+
className="bg-opacity-0 rounded-md cursor-pointer hover:bg-white transition duration-350 text-gray-500 hover:text-gray-700"
357+
onClick={() => handleRegenerateMeaning("Trigger")}
358+
>
359+
<RefreshCcwIcon
360+
size={18}
361+
className={meaningLoading ? "animate-spin" : ""}
362+
/>
363+
</Button>
364+
</div>
365+
<div className="border-dashed border-2 rounded-md px-3 py-2 bg-white dark:bg-black text-sm flex-1">
366+
<span>{trigger}</span>
367+
</div>
368+
</section>
369+
)}
370+
{triggerLoading && (
371+
<div className="flex items-center mt-5 text-gray-500">
372+
<LoaderPinwheel className="animate-spin mr-2" color="#6a7282" />
373+
<p>Generating trigger...</p>
374+
</div>
375+
)}
376+
286377
{examples.length > 0 && (
287378
<section>
288379
<div className="flex items-center justify-between mt-5">
@@ -342,6 +433,7 @@ export default function AddWord() {
342433
<WordCard
343434
word={getWord?.word || ""}
344435
meaning={getWord?.meaning || ""}
436+
trigger={getWord?.trigger || ""}
345437
examples={getWord?.examples || []}
346438
isOwner={isOwner(getWord?.owner || "")}
347439
/>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { rateLimit } from "@/utility/RatelimitSetup";
2+
3+
export async function POST(request: Request) {
4+
try {
5+
const { word } = await request.json();
6+
7+
const ip =
8+
request.headers.get("x-forwarded-for") ||
9+
request.headers.get("cf-connecting-ip") ||
10+
"unknown";
11+
12+
const { success, reset } = await rateLimit.limit(ip);
13+
14+
if (!success) {
15+
return new Response(
16+
JSON.stringify({
17+
error: "Rate limit exceeded. Please try again later.",
18+
reset,
19+
}),
20+
{ status: 429, headers: { "Content-Type": "application/json" } }
21+
)
22+
}
23+
24+
} catch (error) {
25+
return new Response('Bad Request', { status: 400 });
26+
}
27+
}
Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { google } from "@ai-sdk/google";
1+
import { groq } from "@ai-sdk/groq";
22
import { generateText } from "ai";
33
import { rateLimit } from "@/utility/RatelimitSetup";
4+
import { MODEL } from "@/app/consts";
45

56
export async function POST(request: Request) {
67
try {
@@ -11,7 +12,6 @@ export async function POST(request: Request) {
1112
request.headers.get("cf-connecting-ip") ||
1213
"unknown";
1314

14-
1515
const { success, reset } = await rateLimit.limit(ip);
1616

1717
if (!success) {
@@ -20,27 +20,40 @@ export async function POST(request: Request) {
2020
error: "Rate limit exceeded. Please try again later.",
2121
reset,
2222
}),
23-
{ status: 429, headers: { "Content-Type": "application/json" } }
23+
{ status: 429, headers: { "Content-Type": "application/json" } },
2424
);
2525
}
2626

2727
const { text } = await generateText({
28-
model: google("gemini-2.5-flash-lite-preview-06-17"),
29-
prompt: `Use the given word to write 2 example sentences to showcase how can this word be used in real-life. Make sure you use simple and easy to understand sentences that is not too long or complex
30-
31-
Your response should be in JSON format with a key "examples" containing an array of 2 sentences.
32-
Word:"${word}".`,
33-
})
28+
model: groq(MODEL),
29+
prompt: `
30+
Write 2 natural, real-life sentences using the word "${word}".
31+
32+
Sentence requirements:
33+
1. First sentence → general real-life example
34+
2. Second sentence → personal-style example (use "I" or "my")
35+
36+
Rules:
37+
- keep sentences short and simple
38+
- use everyday situations (friends, college, work, conversations)
39+
- avoid formal or textbook tone
40+
- make it feel like something someone would actually say
41+
42+
Return JSON only:
43+
{
44+
"examples": ["...", "..."]
45+
}
46+
`,
47+
});
3448

35-
const cleaned = text.replace(/^```json\s*/, '').replace(/```$/, '');
49+
const cleaned = text.replace(/^```json\s*/, "").replace(/```$/, "");
3650
const parsed = JSON.parse(cleaned);
3751

3852
return new Response(JSON.stringify({ examples: parsed.examples }), {
3953
status: 200,
4054
headers: { "Content-Type": "application/json" },
4155
});
42-
4356
} catch (err) {
44-
return new Response('Bad Request', { status: 400 });
57+
return new Response("Bad Request", { status: 400 });
4558
}
4659
}

0 commit comments

Comments
 (0)