Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .claude/worktrees/sharp-lehmann
Submodule sharp-lehmann deleted from 0e187b
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@mantine/carousel": "^8.3.10",
"@mantine/core": "^8.3.10",
"@mantine/dates": "^8.3.10",
Expand Down
64 changes: 64 additions & 0 deletions src/app/api/emoji-suggest/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { GoogleGenerativeAI } from "@google/generative-ai";
import { NextRequest, NextResponse } from "next/server";

const genAI = new GoogleGenerativeAI(
process.env.GOOGLE_GENERATIVE_AI_API_KEY!
);

// In-memory store: IP → { request count, window expiry }
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT = 20; // max requests per IP per window
const WINDOW_MS = 60_000; // 1 minute

// Returns true if IP has exceeded the rate limit. Prunes expired entries on each call.
function isRateLimited(ip: string): boolean {
const now = Date.now();
for (const [key, val] of rateLimitMap) {
if (now > val.resetAt) rateLimitMap.delete(key);
}
const entry = rateLimitMap.get(ip);
if (!entry || now > entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return false;
}
if (entry.count >= RATE_LIMIT) return true;
entry.count++;
return false;
}

export async function POST(req: NextRequest) {
const ip =
req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? "unknown";

if (isRateLimited(ip)) {
Comment thread
guid3d marked this conversation as resolved.
return NextResponse.json(
{ error: "Too many requests" },
{ status: 429 }
);
}

const { name } = await req.json();
if (!name?.trim()) return NextResponse.json({ emojis: [] });

Comment thread
guid3d marked this conversation as resolved.
// Sanitize: cap length and strip quote chars to prevent prompt injection
const safeName = String(name).trim().slice(0, 100).replace(/["\\`]/g, "");
if (!safeName) return NextResponse.json({ emojis: [] });

try {
const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash-lite" });
const result = await model.generateContent(
`Given the transaction name: "${safeName}", suggest 5 relevant emojis. Reply with ONLY the 5 emojis separated by spaces, nothing else. No words, no punctuation, just emojis.`
);
const text = result.response.text().trim();
// Segment by grapheme cluster so multi-codepoint emojis (e.g. 👨‍👩‍👧) aren't split,
// then keep only pictographic emojis (excludes digits/punctuation that match \p{Emoji})
const emojis = [...new Intl.Segmenter().segment(text)]
.map((s) => s.segment)
.filter((s) => /\p{Extended_Pictographic}/u.test(s))
.slice(0, 5);

return NextResponse.json({ emojis });
} catch {
return NextResponse.json({ emojis: [] });
}
}
4 changes: 2 additions & 2 deletions src/app/group/[groupId]/components/TabTransactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
Text,
Title,
rem,
useMantineColorScheme,
useComputedColorScheme,
} from "@mantine/core";
import { TotalSpendData } from "@/types";
import UserAvatar from "@/components/UserAvatar";
Expand All @@ -26,7 +26,7 @@ type TabTransactionsProps = {
};

const TabTransactions = ({ groupData, localUserId }: TabTransactionsProps) => {
const { colorScheme } = useMantineColorScheme();
const colorScheme = useComputedColorScheme();
const router = useRouter();
const { groupId } = useParams<{ groupId: string }>();
const { data, isPending, error } = useTransactions(groupId);
Expand Down
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const RootLayout = ({ children }: { children: React.ReactNode }) => {
/>
</head>
<body>
<MantineProvider theme={theme}>
<MantineProvider theme={theme} defaultColorScheme="auto">
<ReactQueryClientProvider>{children}</ReactQueryClientProvider>
</MantineProvider>
</body>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const PageSetDetails = ({ form }: PageSetDetailsProps) => {
</Text>
</Center>
<Center>
<EmojiActionButtion form={form} />
<EmojiActionButtion form={form} suggestionQuery={form.values.name} />

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in commit 3d1db6c.

The transaction details page now only passes suggestionQuery in create mode, so edit mode no longer triggers auto-apply from AI suggestions and existing avatars are preserved unless explicitly changed.

Screenshot: behavior-only fix (no visual UI change in this update).

</Center>
<BigTextInput
placeholder="Name"
Expand Down
11 changes: 10 additions & 1 deletion src/components/AddParticipantModal/components/PageSetPayment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { UseFormReturnType } from "@mantine/form";
import { Participant } from "@/types";
import BigTextInput from "@/components/BigTextInput";
import PaymentForm from "@/components/PaymentForm";
import { randomPersonEmoji } from "@/utils/randomEmoji";

type PageSetPaymentProps = {
disabledPreferredPaymentMethod?: boolean;
Expand All @@ -18,7 +19,15 @@ const PageSetPayment = ({
<Container>
<Stack gap="xs">
<Center>
<EmojiActionButtion form={form} />
<EmojiActionButtion
form={form}
onRandomize={() =>
form.setFieldValue("avatar", {
emoji: randomPersonEmoji(),
unified: "",
})
}
/>
</Center>
<Center>
<BigTextInput
Expand Down
3 changes: 2 additions & 1 deletion src/components/AddParticipantModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ const AddParticipantModal = ({
: null,
"paymentMethod.iban":
!disabledPreferredPaymentMethod &&
values.selectedPaymentMethod === PaymentMethodType.Iban
values.selectedPaymentMethod === PaymentMethodType.Iban &&
values.paymentMethod.iban.trim().length > 0
? validateIban(values.paymentMethod.iban)
Comment thread
guid3d marked this conversation as resolved.
: null,
};
Expand Down
94 changes: 74 additions & 20 deletions src/components/EmojiActionButtion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import {
Modal as MantineModal,
Stack,
Indicator,
Loader,
} from "@mantine/core";
import { Theme } from "emoji-picker-react";
import dynamic from "next/dynamic";
import { useDisclosure, useMediaQuery } from "@mantine/hooks";
import { IconChevronLeft } from "@tabler/icons-react";
import { IconChevronLeft, IconPencil, IconRefresh } from "@tabler/icons-react";
import { UseFormReturnType } from "@mantine/form";
import { IconPencil } from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react";
import { useEmojiSuggestions } from "@/hooks/useEmojiSuggestions";

const Picker = dynamic(
() => {
Expand All @@ -23,15 +25,38 @@ const Picker = dynamic(

type EmojiActionButtionProps = {
form: UseFormReturnType<any>;
suggestionQuery?: string;
Comment thread
guid3d marked this conversation as resolved.
onRandomize?: () => void;
};

const EmojiActionButtion = ({ form }: EmojiActionButtionProps) => {
const EmojiActionButtion = ({
form,
suggestionQuery,
onRandomize,
}: EmojiActionButtionProps) => {
const [opened, { open, close }] = useDisclosure(false);
const isMobile = useMediaQuery("(max-width: 50em)") || false;
// const [chosenEmoji, setChosenEmoji] = useState<StoreEmojiData>({
// emoji: form.values.avatar.emoji,
// unified: form.values.avatar.unified,
// });
const wasManuallySet = useRef(false);
const [suggestionIndex, setSuggestionIndex] = useState(0);
const { suggestions, loading } = useEmojiSuggestions(suggestionQuery ?? "");

useEffect(() => {
Comment thread
guid3d marked this conversation as resolved.
if (!wasManuallySet.current && suggestions.length > 0) {
form.setFieldValue("avatar", { emoji: suggestions[0], unified: "" });
setSuggestionIndex(0);
}
}, [suggestions]);
Comment thread
guid3d marked this conversation as resolved.

const handleCycle = () => {
if (suggestions.length < 2) return;
const next = (suggestionIndex + 1) % suggestions.length;
setSuggestionIndex(next);
form.setFieldValue("avatar", { emoji: suggestions[next], unified: "" });
};

const showCycleButton =
suggestions.length > 1 && !wasManuallySet.current && !opened;

return (
<>
<MantineModal.Root
Expand Down Expand Up @@ -68,15 +93,14 @@ const EmojiActionButtion = ({ form }: EmojiActionButtionProps) => {
</Center>
<Center>
<Picker
// style={{ border: "none" }}
height={390}
theme={Theme.AUTO}
onEmojiClick={(res) => {
wasManuallySet.current = true;
form.setFieldValue("avatar", {
emoji: res.emoji,
unified: res.unified,
});
// setChosenEmoji({ emoji: res.emoji, unified: res.unified });
close();
}}
previewConfig={{ showPreview: false }}
Expand All @@ -90,23 +114,53 @@ const EmojiActionButtion = ({ form }: EmojiActionButtionProps) => {
{!opened && (
<Indicator
color="gray"
onClick={open}
offset={13}
position="bottom-end"
size={35}
withBorder
label={<IconPencil size={20} width={20} height={20} stroke={1.5} />}
onClick={open}
>
<ActionIcon
variant="default"
size={rem(100)}
radius={rem(100)}
onClick={open}
>
<Title order={1} style={{ fontSize: rem(60) }}>
{form.values.avatar.emoji}
</Title>
</ActionIcon>
<div style={{ position: "relative" }}>
<ActionIcon
variant="default"
size={rem(100)}
radius={rem(100)}
onClick={open}
style={{
opacity: loading && !wasManuallySet.current ? 0.5 : 1,
transition: "opacity 0.2s",
}}
>
{loading && !wasManuallySet.current ? (
<Loader size="sm" />
) : (
<Title order={1} style={{ fontSize: rem(60) }}>
{form.values.avatar.emoji}
</Title>
)}
</ActionIcon>
{(showCycleButton || onRandomize) && (
<ActionIcon
variant="default"
size={rem(28)}
radius={rem(28)}
style={{
position: "absolute",
bottom: 0,
left: 0,
zIndex: 1,
}}
onClick={(e) => {
e.stopPropagation();
if (onRandomize) onRandomize();
else handleCycle();
}}
>
<IconRefresh size={14} stroke={1.5} />
</ActionIcon>
)}
</div>
</Indicator>
)}
</>
Expand Down
28 changes: 15 additions & 13 deletions src/components/ToggleDarkLightMode.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,29 @@
import {
rem,
UnstyledButton,
useComputedColorScheme,
useMantineColorScheme,
} from "@mantine/core";
import { IconMoonFilled, IconSunFilled } from "@tabler/icons-react";
import {
IconBrightnessAutoFilled,
IconMoonFilled,
IconSunFilled,
} from "@tabler/icons-react";

const ToggleDarkLightMode = () => {
const { colorScheme, setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme();
const toggleColorScheme = () => {
setColorScheme(computedColorScheme === "dark" ? "light" : "dark");

const cycleColorScheme = () => {
if (colorScheme === "light") setColorScheme("dark");
else if (colorScheme === "dark") setColorScheme("auto");
else setColorScheme("light");
};

return (
<>
<UnstyledButton lightHidden onClick={() => toggleColorScheme()}>
<IconMoonFilled style={{ width: rem(16) }} />
</UnstyledButton>
<UnstyledButton darkHidden onClick={() => toggleColorScheme()}>
<IconSunFilled style={{ width: rem(16) }} />
</UnstyledButton>
</>
<UnstyledButton onClick={cycleColorScheme}>
{colorScheme === "light" && <IconSunFilled style={{ width: rem(16) }} />}
{colorScheme === "dark" && <IconMoonFilled style={{ width: rem(16) }} />}
{colorScheme === "auto" && <IconBrightnessAutoFilled style={{ width: rem(16) }} />}
</UnstyledButton>
);
};

Expand Down
Loading