Skip to content

Commit 222f252

Browse files
committed
Add GitHub stars and issue request CTA
1 parent cf355c1 commit 222f252

8 files changed

Lines changed: 162 additions & 201 deletions

File tree

‎src/components/github-stars.tsx‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { Tooltip } from "@/components/ui/tooltip";
5+
import { OddIcon } from "@/components/ui/odd-icon";
6+
import { cn } from "@/lib/utils";
7+
8+
const REPO_URL = "https://github.com/jasperdevs/oddicons";
9+
const API_URL = "https://api.github.com/repos/jasperdevs/oddicons";
10+
const STORAGE_KEY = "oddicons:github-stars";
11+
const REFRESH_MS = 5 * 60 * 1000;
12+
13+
function formatStars(count: number): string {
14+
return new Intl.NumberFormat("en-US", {
15+
notation: "compact",
16+
maximumFractionDigits: 1,
17+
}).format(count).toLowerCase();
18+
}
19+
20+
function readCachedStars(): number | null {
21+
if (typeof window === "undefined") return null;
22+
const raw = window.localStorage.getItem(STORAGE_KEY);
23+
if (!raw) return null;
24+
const parsed = Number(raw);
25+
return Number.isFinite(parsed) ? parsed : null;
26+
}
27+
28+
export function GitHubStars() {
29+
const [stars, setStars] = useState<number | null>(null);
30+
31+
useEffect(() => {
32+
setStars(readCachedStars());
33+
34+
const load = async (signal?: AbortSignal) => {
35+
try {
36+
const res = await fetch(API_URL, {
37+
signal,
38+
headers: { Accept: "application/vnd.github+json" },
39+
cache: "no-store",
40+
});
41+
if (!res.ok) return;
42+
const data = await res.json();
43+
const next = Number(data.stargazers_count);
44+
if (!Number.isFinite(next)) return;
45+
setStars(next);
46+
window.localStorage.setItem(STORAGE_KEY, String(next));
47+
} catch {
48+
// Keep the cached value if GitHub is unavailable or rate limited.
49+
}
50+
};
51+
52+
const controller = new AbortController();
53+
load(controller.signal);
54+
const interval = window.setInterval(() => load(), REFRESH_MS);
55+
return () => {
56+
controller.abort();
57+
window.clearInterval(interval);
58+
};
59+
}, []);
60+
61+
return (
62+
<Tooltip content="open GitHub repo">
63+
<a
64+
href={REPO_URL}
65+
target="_blank"
66+
rel="noreferrer"
67+
aria-label={stars === null ? "open GitHub repo" : `open GitHub repo, ${stars} stars`}
68+
className={cn(
69+
"flex h-11 items-center gap-2 border-l border-border/60 px-3 text-[14px] font-medium text-foreground",
70+
"transition-colors duration-[180ms] hover:bg-foreground/5"
71+
)}
72+
>
73+
<OddIcon name="github" size={20} />
74+
<span className="hidden min-w-[2.2rem] text-left tabular-nums min-[420px]:inline">
75+
{stars === null ? "..." : formatStars(stars)}
76+
</span>
77+
</a>
78+
</Tooltip>
79+
);
80+
}

‎src/components/icon-card.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,13 @@ export function IconCard({
120120
}, [thumbUrl]);
121121

122122
const borderBackground = useMemo(() => {
123+
if (file === "check.png") return null;
123124
if (!colors || colors.length === 0) return null;
124125
const c1 = colors[0];
125126
const c2 = colors[1] ?? colors[0];
126127
const c3 = colors[2] ?? c2;
127128
return `radial-gradient(circle 360px at var(--mx, -800px) var(--my, -800px), rgba(255,255,255,0.95) 0%, ${c1} 22%, rgba(255,255,255,0.7) 44%, ${c2} 66%, ${c3} 82%, transparent 100%)`;
128-
}, [colors]);
129+
}, [colors, file]);
129130

130131
useEffect(() => {
131132
const el = cardRef.current;

‎src/components/icon-gallery.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ function BottomBar({
334334
onClick={onOpenRequest}
335335
className="h-11 px-5 text-[14px]"
336336
>
337-
request
337+
send issue
338338
</Button>
339339
<SortDropdown mode={sort} onChange={onChangeSort} />
340340
<AddAllButton items={items} basePath={basePath} />

‎src/components/request-form.tsx‎

Lines changed: 39 additions & 164 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,8 @@
11
"use client";
22

3-
import { useState } from "react";
4-
import { Button } from "@/components/ui/button";
5-
import { OddIcon, oddIconComponent } from "@/components/ui/odd-icon";
6-
import { cn } from "@/lib/utils";
3+
import { OddIcon } from "@/components/ui/odd-icon";
74

8-
const GmailIcon = oddIconComponent("gmail");
9-
10-
const EMAIL = "jasper.mceligott@gmail.com";
11-
12-
const EMAIL_TEMPLATE = `hi jasper,
13-
14-
i'd love to see an icon for:
15-
[describe what you want]
16-
17-
thanks!`;
18-
19-
function CopyBadgeIcon({ size = 12 }: { size?: number }) {
20-
return (
21-
<svg
22-
xmlns="http://www.w3.org/2000/svg"
23-
width={size}
24-
height={size}
25-
viewBox="0 0 24 24"
26-
fill="none"
27-
stroke="currentColor"
28-
strokeWidth={1.75}
29-
strokeLinecap="round"
30-
strokeLinejoin="round"
31-
aria-hidden
32-
>
33-
<rect x="8" y="8" width="13" height="13" rx="2.5" />
34-
<path d="M5 15H4.5A2.5 2.5 0 0 1 2 12.5v-8A2.5 2.5 0 0 1 4.5 2h8A2.5 2.5 0 0 1 15 4.5V5" />
35-
</svg>
36-
);
37-
}
38-
39-
interface ContactRow {
40-
id: string;
41-
label: string;
42-
iconName: string;
43-
href?: string;
44-
copyValue?: string;
45-
}
46-
47-
const CONTACTS: ContactRow[] = [
48-
{ id: "x", label: "@jasperdevs", iconName: "x-twitter", href: "https://x.com/jasperdevs" },
49-
{ id: "github", label: "jasperdevs", iconName: "github", href: "https://github.com/jasperdevs" },
50-
{ id: "discord", label: "jasperdevs", iconName: "discord", copyValue: "jasperdevs" },
51-
];
5+
const ISSUES_URL = "https://github.com/jasperdevs/oddicons/issues/new";
526

537
interface RequestFormProps {
548
heading?: string;
@@ -57,125 +11,46 @@ interface RequestFormProps {
5711
}
5812

5913
export function RequestForm({
60-
heading = "send an email",
61-
subheading = "request an icon or just say hi",
14+
heading = "send an issue",
15+
subheading = "tell us what icon you want on GitHub",
6216
hideHeader = false,
6317
}: RequestFormProps) {
64-
const [subject, setSubject] = useState("icon request");
65-
const [message, setMessage] = useState(EMAIL_TEMPLATE);
66-
const [copiedId, setCopiedId] = useState<string | null>(null);
67-
68-
const canSubmit = message.trim().length > 0;
69-
70-
const handleSubmit = (e: React.FormEvent) => {
71-
e.preventDefault();
72-
if (!canSubmit) return;
73-
const subj = subject.trim() || "icon request";
74-
const href = `mailto:${EMAIL}?subject=${encodeURIComponent(subj)}&body=${encodeURIComponent(message.trim())}`;
75-
window.open(href, "_blank", "noopener,noreferrer");
76-
};
77-
78-
const handleCopy = async (id: string, value: string) => {
79-
try {
80-
await navigator.clipboard.writeText(value);
81-
setCopiedId(id);
82-
window.setTimeout(() => setCopiedId((c) => (c === id ? null : c)), 1600);
83-
} catch {
84-
// ignore
85-
}
86-
};
87-
8818
return (
89-
<>
90-
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
91-
{!hideHeader && (
92-
<div className="flex flex-col gap-0.5">
93-
<h2 className="text-[15px] font-semibold tracking-tight text-foreground">
94-
{heading}
95-
</h2>
96-
<p className="text-[12px] text-muted-foreground">
97-
{subheading}
98-
</p>
99-
</div>
100-
)}
101-
102-
<input
103-
type="text"
104-
value={subject}
105-
onChange={(e) => setSubject(e.target.value)}
106-
placeholder="subject (optional)"
107-
className="h-11 w-full rounded-lg border border-border bg-sidebar px-3 text-[13px] text-foreground placeholder:text-muted-foreground/70 outline-none transition-colors duration-[180ms] focus:border-foreground/30"
108-
/>
109-
110-
<textarea
111-
value={message}
112-
onChange={(e) => setMessage(e.target.value)}
113-
rows={6}
114-
required
115-
className="scrollbar-custom w-full resize-none rounded-lg border border-border bg-sidebar px-3 py-2.5 text-[13px] leading-[1.55] text-foreground placeholder:text-muted-foreground/70 outline-none transition-colors duration-[180ms] focus:border-foreground/30"
116-
/>
117-
118-
<Button
119-
type="submit"
120-
variant="primary"
121-
size="lg"
122-
leadingIcon={GmailIcon}
123-
disabled={!canSubmit}
124-
className="h-11 w-full px-5 text-[14px]"
125-
>
126-
send an email
127-
</Button>
128-
</form>
129-
130-
<div className="mt-4 grid grid-cols-3 gap-1.5 border-t border-border pt-4">
131-
{CONTACTS.map((c) => {
132-
const copied = copiedId === c.id;
133-
const cls = cn(
134-
"group/chip relative flex h-11 items-center justify-center gap-2 rounded-lg border border-border bg-sidebar text-[12px] font-medium text-muted-foreground transition-all duration-[180ms]",
135-
"hover:-translate-y-0.5 hover:border-foreground/30 hover:bg-accent hover:text-foreground"
136-
);
137-
const content = (
138-
<>
139-
<span className="inline-flex shrink-0">
140-
{copied ? <OddIcon name="check" size={18} /> : <OddIcon name={c.iconName} size={20} />}
141-
</span>
142-
<span className="truncate">
143-
{copied ? "copied" : c.label}
144-
</span>
145-
</>
146-
);
147-
if (c.href) {
148-
return (
149-
<a
150-
key={c.id}
151-
href={c.href}
152-
target="_blank"
153-
rel="noreferrer"
154-
className={cls}
155-
title={c.id}
156-
>
157-
{content}
158-
</a>
159-
);
160-
}
161-
return (
162-
<button
163-
key={c.id}
164-
type="button"
165-
onClick={() => c.copyValue && handleCopy(c.id, c.copyValue)}
166-
className={cls}
167-
title={c.copyValue}
168-
>
169-
{content}
170-
{!copied && (
171-
<span className="absolute right-1.5 top-1.5 opacity-40 transition-opacity group-hover/chip:opacity-70">
172-
<CopyBadgeIcon size={12} />
173-
</span>
174-
)}
175-
</button>
176-
);
177-
})}
19+
<div className="flex flex-col gap-4">
20+
{!hideHeader && (
21+
<div className="flex flex-col gap-0.5">
22+
<h2 className="text-[15px] font-semibold tracking-tight text-foreground">
23+
{heading}
24+
</h2>
25+
<p className="text-[12px] text-muted-foreground">
26+
{subheading}
27+
</p>
28+
</div>
29+
)}
30+
31+
<div className="flex items-start gap-3 rounded-xl border border-border bg-sidebar p-3.5">
32+
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-card">
33+
<OddIcon name="github" size={28} />
34+
</span>
35+
<div className="flex min-w-0 flex-1 flex-col gap-1">
36+
<p className="text-[13.5px] font-medium leading-[1.45] text-foreground">
37+
Send an issue with the icon you want.
38+
</p>
39+
<p className="text-[12px] leading-[1.55] text-muted-foreground">
40+
Requests are tracked in GitHub issues so they do not get lost.
41+
</p>
42+
</div>
17843
</div>
179-
</>
44+
45+
<a
46+
href={ISSUES_URL}
47+
target="_blank"
48+
rel="noreferrer"
49+
className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-xl bg-foreground px-5 text-[14px] font-medium text-background outline-none transition-all duration-80 hover:bg-foreground/90 focus-visible:ring-1 focus-visible:ring-[#6B97FF] active:bg-foreground/80"
50+
>
51+
<OddIcon name="github" size={20} />
52+
send GitHub issue
53+
</a>
54+
</div>
18055
);
18156
}

‎src/components/request-modal.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export function RequestModal({ open, onClose, anchorRef }: RequestModalProps) {
9292
key="panel"
9393
ref={panelRef}
9494
role="dialog"
95-
aria-label="request an icon"
95+
aria-label="send an icon request issue"
9696
initial={{ opacity: 0, y: 6, scale: 0.96 }}
9797
animate={{ opacity: 1, y: 0, scale: 1 }}
9898
exit={{ opacity: 0, y: 6, scale: 0.96 }}

0 commit comments

Comments
 (0)