Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 15 additions & 22 deletions src/app/dashboard/RoastHypeWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import { useState } from 'react';
import { Copy, Sparkles, Flame } from 'lucide-react';
import { Sparkles, Flame } from 'lucide-react';
import CopyToClipboardButton from '@/components/CopyToClipboardButton';

interface UserStats {
commits: number;
Expand All @@ -14,12 +15,14 @@ export default function RoastHypeWidget({ stats }: { stats: UserStats }) {
const [mode, setMode] = useState<'roast' | 'hype'>('hype');
const [loading, setLoading] = useState(false);
const [output, setOutput] = useState<string | null>(null);
const [copied, setCopied] = useState(false);

const shareText = output
? `DevTrack ${mode === 'hype' ? 'Hype' : 'Roast'}:\n"${output}"\n\nTrack your stats at devtrack.app! 🚀`
: "";

const generateContent = async () => {
setLoading(true);
setOutput(null);
setCopied(false);

try {
const response = await fetch('/api/ai/roast', {
Expand All @@ -42,14 +45,6 @@ export default function RoastHypeWidget({ stats }: { stats: UserStats }) {
}
};

const copyToClipboard = () => {
if (output) {
navigator.clipboard.writeText(`DevTrack ${mode === 'hype' ? 'Hype' : 'Roast'}:\n"${output}"\n\nTrack your stats at devtrack.app! 🚀`);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};

return (
<div className="p-6 bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-sm w-full">
<div className="flex justify-between items-center mb-6">
Expand Down Expand Up @@ -93,17 +88,15 @@ export default function RoastHypeWidget({ stats }: { stats: UserStats }) {
{output && (
<div className="mt-5 p-4 bg-[var(--background)] border border-[var(--border)] rounded-lg relative group">
<p className="text-[var(--foreground)] pr-6 text-sm italic">&ldquo;{output}&rdquo;</p>
<button
onClick={copyToClipboard}
className="absolute top-3 right-3 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
title="Copy to clipboard"
>
{copied ? (
<span className="text-[10px] text-green-500 font-bold uppercase tracking-wider">Copied!</span>
) : (
<Copy size={16} />
)}
</button>
<CopyToClipboardButton
value={shareText}
iconOnly
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-8 w-8 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
ariaLabel="Copy to clipboard"
copiedLabel="Copied!"
/>
</div>
)}
</div>
Expand Down
46 changes: 11 additions & 35 deletions src/app/dashboard/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import WebhookManager from "@/components/webhook/WebhookManager";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import CopyToClipboardButton from "@/components/CopyToClipboardButton";
import { useLocale, useTranslations } from "next-intl";
import { localeMetadata, locales, type AppLocale } from "@/i18n/config";

Expand Down Expand Up @@ -178,7 +179,6 @@
const [saving, setSaving] = useState(false);
const [webhookUrl, setWebhookUrl] = useState<string | null>(null);
const [webhookSaving, setWebhookSaving] = useState(false);
const [copied, setCopied] = useState(false);
const [removeError, setRemoveError] = useState<string | null>(null);
const [accountsError, setAccountsError] = useState<string | null>(null);
const [removingAccountId, setRemovingAccountId] = useState<string | null>(
Expand All @@ -197,7 +197,6 @@
const [testingDiscord, setTestingDiscord] = useState(false);
const [discordMutedUntil, setDiscordMutedUntil] = useState<string | null>(null);
const [muteDuration, setMuteDuration] = useState<number>(1);
const copyResetTimerRef = useRef<number | null>(null);

// GitHub Orgs States
const [orgAccounts, setOrgAccounts] = useState<any[]>([]);
Expand Down Expand Up @@ -789,32 +788,6 @@
}
};

const copyShareLink = () => {
if (!profileUrl) return;

if (copyResetTimerRef.current) {
window.clearTimeout(copyResetTimerRef.current);
copyResetTimerRef.current = null;
}

if (!navigator.clipboard?.writeText) {
toast.error("Clipboard access is not available in this browser.");
return;
}

navigator.clipboard.writeText(profileUrl).then(() => {
setCopied(true);
toast.success("Link copied successfully!");
copyResetTimerRef.current = window.setTimeout(() => {
setCopied(false);
copyResetTimerRef.current = null;
}, 2000);
}).catch((err) => {
console.error("Clipboard copy failed:", err);
toast.error("Failed to copy profile URL");
});
};

const handleRemoveAccount = async (githubId: string) => {
setRemoveError(null);
setRemovingAccountId(githubId);
Expand Down Expand Up @@ -969,15 +942,18 @@
aria-label="Public profile URL"
className="min-w-0 flex-1 rounded-xl border border-[var(--border)] bg-[var(--control)] px-4 py-2 text-sm text-[var(--card-foreground)] outline-none transition-colors focus:border-[var(--accent)]"
/>
<Button
type="button"
onClick={copyShareLink}
<CopyToClipboardButton
value={profileUrl}
label="Copy"
copiedLabel="Copied!"
variant="secondary"
className="w-full sm:w-auto"
aria-label="Copy public profile URL"
>
{copied ? "Copied!" : "Copy"}
</Button>
showToast
successMessage="Link copied successfully!"
errorMessage="Failed to copy profile URL"
ariaLabel="Copy public profile URL"
disabled={!profileUrl}
/>
</div>
{!settings.is_public && (
<p className="mt-3 text-sm text-[var(--muted-foreground)]">
Expand Down Expand Up @@ -1697,7 +1673,7 @@
className="flex items-center justify-between rounded-lg border border-[var(--border)] bg-[var(--control)] p-3"
>
<div className="flex items-center gap-3">
<img

Check warning on line 1676 in src/app/dashboard/settings/page.tsx

View workflow job for this annotation

GitHub Actions / Lint

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src={org.avatarUrl}
alt={org.login}
className="w-8 h-8 rounded"
Expand Down
29 changes: 10 additions & 19 deletions src/components/BadgeSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React, { useState, useEffect, useRef } from "react";
import Image from "next/image";
import CopyToClipboardButton from "@/components/CopyToClipboardButton";

interface BadgeSectionProps {
username: string;
Expand Down Expand Up @@ -137,30 +138,20 @@ function Toast({ visible }: { visible: boolean }) {
* Copyable code block component
*/
function CopyableCodeBlock({ code, onCopySuccess }: { code: string; onCopySuccess?: () => void }) {
const [copied, setCopied] = useState(false);

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
onCopySuccess?.();
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};

return (
<div className="flex items-center justify-between rounded-lg bg-[var(--control)] p-3 border border-[var(--border)]">
<code className="flex-1 text-xs text-[var(--card-foreground)] overflow-auto scrollbar-thin">
{code}
</code>
<button
onClick={handleCopy}
className="ml-2 shrink-0 px-2 py-1 text-xs font-medium rounded bg-[var(--accent)] text-[var(--accent-foreground)] hover:opacity-90 transition-opacity"
>
{copied ? "✓ Copied!" : "Copy"}
</button>
<CopyToClipboardButton
value={code}
label="Copy"
copiedLabel="Copied!"
size="sm"
className="ml-2 shrink-0 px-2 py-1 text-xs h-auto"
onCopied={onCopySuccess}
ariaLabel="Copy badge markdown"
/>
</div>
);
}
60 changes: 14 additions & 46 deletions src/components/CopyLinkButton.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,24 @@
"use client";

import { useState } from "react";
import { toast } from "sonner";
import CopyToClipboardButton from "@/components/CopyToClipboardButton";

interface CopyLinkButtonProps {
url: string;
}

export default function CopyLinkButton({ url }: CopyLinkButtonProps) {
const [copied, setCopied] = useState(false);

const handleCopy = async () => {
// Fallback strategy for older browsers
if (!navigator.clipboard) {
const textArea = document.createElement("textarea");
textArea.value = url;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
triggerSuccess();
} catch (err) {
toast.error("Failed to copy link.");
}
document.body.removeChild(textArea);
return;
}

// Modern browser copying execution
try {
await navigator.clipboard.writeText(url);
triggerSuccess();
} catch (err) {
toast.error("Failed to copy link.");
}
};

const triggerSuccess = () => {
setCopied(true);
toast.success("Link copied!", { duration: 2000 });
setTimeout(() => setCopied(false), 2000);
};

return (
<button
onClick={handleCopy}
type="button"
aria-label="Copy profile link"
title="Copy profile link"
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md border border-gray-300 bg-white text-gray-700 shadow-sm hover:bg-gray-50 focus-visible:ring-2 focus-visible:ring-indigo-500 transition-colors dark:bg-gray-800 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-700"
>
<span>{copied ? "Copied!" : "Copy link"}</span>
</button>
<CopyToClipboardButton
value={url}
label="Copy link"
copiedLabel="Copied!"
variant="outline"
size="sm"
showToast
successMessage="Link copied!"
errorMessage="Failed to copy link."
ariaLabel="Copy profile link"
className="text-xs font-medium"
/>
);
}
}
87 changes: 87 additions & 0 deletions src/components/CopyToClipboardButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"use client";

import { Check, Copy } from "lucide-react";
import { Button, type ButtonProps } from "@/components/ui/button";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { cn } from "@/lib/utils";

export interface CopyToClipboardButtonProps
extends Omit<ButtonProps, "onClick" | "children"> {
value: string;
label?: string;
copiedLabel?: string;
iconOnly?: boolean;
showToast?: boolean;
resetDelay?: number;
successMessage?: string;
errorMessage?: string;
ariaLabel?: string;
onCopied?: () => void;
}

export default function CopyToClipboardButton({
value,
label = "Copy",
copiedLabel = "Copied!",
iconOnly = false,
showToast = false,
resetDelay = 2500,
successMessage,
errorMessage,
ariaLabel,
onCopied,
className,
variant = "outline",
size,
disabled,
...buttonProps
}: CopyToClipboardButtonProps) {
const { copy, copied } = useCopyToClipboard({
resetDelay,
showToast,
successMessage,
errorMessage,
onSuccess: onCopied,
});

const resolvedSize = size ?? (iconOnly ? "icon" : "default");
const defaultAriaLabel = ariaLabel ?? label;

return (
<Button
type="button"
variant={variant}
size={resolvedSize}
disabled={disabled || !value}
aria-live="polite"
aria-label={copied ? copiedLabel : defaultAriaLabel}
title={copied ? copiedLabel : defaultAriaLabel}
className={cn(
copied && "text-[var(--success)] border-[var(--success)]/30",
className,
)}
onClick={() => {
void copy(value);
}}
{...buttonProps}
>
{copied ? (
<>
<Check
className={cn(
"h-4 w-4 shrink-0 text-[var(--success)] animate-in zoom-in-50 duration-200",
iconOnly ? "" : "",
)}
aria-hidden="true"
/>
{!iconOnly && <span>{copiedLabel}</span>}
</>
) : (
<>
<Copy className="h-4 w-4 shrink-0" aria-hidden="true" />
{!iconOnly && <span>{label}</span>}
</>
)}
</Button>
);
}
Loading
Loading