Skip to content
Open
Show file tree
Hide file tree
Changes from all 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)) {
return NextResponse.json(
{ error: "Too many requests" },
{ status: 429 }
);
}

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

// 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} />
</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)
: 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]);

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