mirror of
https://github.com/Such-Software/smirk-website.git
synced 2026-10-17 05:51:28 +02:00
feat: discord integration
This commit is contained in:
+103
-1
@@ -10,6 +10,8 @@ import {
|
||||
unlinkSocial,
|
||||
getMyUsername,
|
||||
setUsername,
|
||||
getDiscordAuthUrl,
|
||||
completeDiscordOAuth,
|
||||
type MeResponse,
|
||||
type LinkedSocial,
|
||||
} from '@/lib/api';
|
||||
@@ -35,8 +37,44 @@ export default function SettingsPage() {
|
||||
const [linkError, setLinkError] = useState<string | null>(null);
|
||||
const [polling, setPolling] = useState(false);
|
||||
|
||||
// Discord linking state
|
||||
const [discordLinking, setDiscordLinking] = useState(false);
|
||||
const [discordError, setDiscordError] = useState<string | null>(null);
|
||||
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('smirk_token') : null;
|
||||
|
||||
// Handle Discord OAuth callback (code + state in URL params)
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !token) return;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
|
||||
if (code && state) {
|
||||
// Clean URL immediately
|
||||
window.history.replaceState({}, '', '/settings');
|
||||
|
||||
// Complete Discord OAuth
|
||||
setDiscordLinking(true);
|
||||
setDiscordError(null);
|
||||
|
||||
completeDiscordOAuth(token, code, state)
|
||||
.then((linkedSocial) => {
|
||||
// Refresh socials list
|
||||
return getLinkedSocials(token).then((data) => {
|
||||
setSocials(data.socials);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
setDiscordError(err instanceof Error ? err.message : 'Failed to link Discord');
|
||||
})
|
||||
.finally(() => {
|
||||
setDiscordLinking(false);
|
||||
});
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
@@ -144,6 +182,21 @@ export default function SettingsPage() {
|
||||
setPolling(false);
|
||||
};
|
||||
|
||||
const handleLinkDiscord = async () => {
|
||||
if (!token) return;
|
||||
setDiscordLinking(true);
|
||||
setDiscordError(null);
|
||||
|
||||
try {
|
||||
const { auth_url } = await getDiscordAuthUrl(token);
|
||||
// Redirect to Discord OAuth
|
||||
window.location.href = auth_url;
|
||||
} catch (err) {
|
||||
setDiscordError(err instanceof Error ? err.message : 'Failed to start Discord link');
|
||||
setDiscordLinking(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@@ -164,6 +217,7 @@ export default function SettingsPage() {
|
||||
}
|
||||
|
||||
const telegramSocial = socials.find((s) => s.platform === 'telegram');
|
||||
const discordSocial = socials.find((s) => s.platform === 'discord');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8 max-w-2xl mx-auto">
|
||||
@@ -352,9 +406,57 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Discord */}
|
||||
<div className="bg-zinc-900 rounded-xl p-4 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-[#5865F2] rounded-full flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Discord</p>
|
||||
{discordSocial?.verified ? (
|
||||
<p className="text-sm text-green-400">
|
||||
{discordSocial.display_name || discordSocial.username || 'Linked'}
|
||||
</p>
|
||||
) : discordLinking ? (
|
||||
<p className="text-sm text-yellow-400">Linking...</p>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Not linked</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{discordSocial?.verified ? (
|
||||
<button
|
||||
onClick={() => handleUnlink('discord')}
|
||||
className="px-3 py-1 text-sm text-red-400 border border-red-400/50 rounded hover:bg-red-400/10"
|
||||
>
|
||||
Unlink
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleLinkDiscord}
|
||||
disabled={discordLinking}
|
||||
className="px-4 py-2 bg-[#fbeb0a] text-black font-medium rounded-lg hover:bg-[#d4c708] disabled:opacity-50"
|
||||
>
|
||||
{discordLinking ? 'Linking...' : 'Link'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{discordError && (
|
||||
<div className="mt-4 pt-4 border-t border-zinc-800">
|
||||
<p className="text-sm text-red-400">{discordError}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Future platforms */}
|
||||
<div className="space-y-3">
|
||||
{['Discord', 'Signal', 'Matrix'].map((platform) => (
|
||||
{['Signal', 'Matrix'].map((platform) => (
|
||||
<div key={platform} className="bg-zinc-900/50 rounded-xl p-4 opacity-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-zinc-700 rounded-full" />
|
||||
|
||||
@@ -294,6 +294,49 @@ export async function getClaimableTips(token: string): Promise<{ tips: Claimable
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Discord OAuth2 Linking
|
||||
// =============================================================================
|
||||
|
||||
export interface DiscordAuthUrlResponse {
|
||||
auth_url: string;
|
||||
}
|
||||
|
||||
export async function getDiscordAuthUrl(token: string): Promise<DiscordAuthUrlResponse> {
|
||||
const res = await fetch(`${API_URL}/api/v1/socials/discord/auth-url`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to get Discord auth URL');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function completeDiscordOAuth(
|
||||
token: string,
|
||||
code: string,
|
||||
state: string
|
||||
): Promise<LinkedSocial> {
|
||||
const res = await fetch(`${API_URL}/api/v1/socials/discord/callback`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ code, state }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to link Discord account');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Username Management
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user