The weeb for the next gen discord boat - Wamellow wamellow.com
bot discord
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

add bluesky authorization flow

Luna 4111f041 49e15c6d

+299 -293
+28
app/(dynamic-assets)/bluesky-client-metadata.json/route.ts
··· 1 + import { getBaseUrl, getCanonicalUrl } from "@/utils/urls"; 2 + 3 + export const revalidate = 691200; // 8 days 4 + 5 + export function GET() { 6 + return Response.json({ 7 + client_id: getCanonicalUrl("bluesky-client-metadata.json"), 8 + client_name: "Wamellow", 9 + client_uri: getBaseUrl(), 10 + logo_uri: getCanonicalUrl("waya-v3.webp"), 11 + tos_uri: getCanonicalUrl("terms"), 12 + policy_uri: getCanonicalUrl("privacy"), 13 + redirect_uris: [ 14 + getCanonicalUrl("login", "bluesky") 15 + ], 16 + scope: "atproto transition:generic", 17 + grant_types: [ 18 + "authorization_code", 19 + "refresh_token" 20 + ], 21 + response_types: [ 22 + "code" 23 + ], 24 + token_endpoint_auth_method: "none", 25 + application_type: "web", 26 + dpop_bound_access_tokens: true 27 + }); 28 + }
+41
app/login/[social]/api.ts
··· 1 + import type { ApiError } from "@/typings"; 2 + 3 + export async function getAuthorizeUrl(social: string): Promise<{ url: string; verifier?: string; } | ApiError> { 4 + const res = await fetch(`${process.env.NEXT_PUBLIC_API}/connections/${social}`, { 5 + headers: { 6 + authorization: process.env.API_SECRET as string 7 + } 8 + }); 9 + 10 + return res.json(); 11 + } 12 + 13 + export async function connect(social: string, session: string, code: string, verifier?: string): Promise<{ success: boolean; } | ApiError> { 14 + const res = await fetch(`${process.env.NEXT_PUBLIC_API}/users/@me/connections/${social}`, { 15 + method: "PUT", 16 + headers: { 17 + "Content-Type": "application/json", 18 + Cookie: `session=${session}`, 19 + authorization: process.env.API_SECRET as string 20 + }, 21 + body: JSON.stringify({ 22 + code, 23 + verifier 24 + }) 25 + }); 26 + 27 + return res.json(); 28 + } 29 + 30 + export async function disconnect(social: string, session: string): Promise<{ success: boolean; } | ApiError> { 31 + const res = await fetch(`${process.env.NEXT_PUBLIC_API}/users/@me/connections/${social}`, { 32 + method: "DELETE", 33 + headers: { 34 + "Content-Type": "application/json", 35 + Cookie: `session=${session}`, 36 + authorization: process.env.API_SECRET as string 37 + } 38 + }); 39 + 40 + return res.json(); 41 + }
+82
app/login/[social]/route.ts
··· 1 + import { cookies } from "next/headers"; 2 + import { redirect } from "next/navigation"; 3 + 4 + import { defaultCookieOptions } from "../route"; 5 + import { connect, disconnect, getAuthorizeUrl } from "./api"; 6 + 7 + const SOCIALS = ["spotify", "bluesky"]; 8 + 9 + export async function GET( 10 + request: Request, 11 + { params }: { params: Promise<{ social: string; }>; } 12 + ) { 13 + const { social } = await params; 14 + 15 + if (!SOCIALS.includes(social)) { 16 + return Response.json({ 17 + status: 400, 18 + message: "Invalid social" 19 + }); 20 + } 21 + 22 + const verifier = `login-code-verifier-${social}`; 23 + const { searchParams } = new URL(request.url); 24 + const jar = await cookies(); 25 + 26 + const logout = searchParams.get("logout"); 27 + const session = jar.get("session"); 28 + 29 + if (!session?.value) { 30 + redirect("/login?callback=" + encodeURIComponent(`/login/${social}${logout === "true" ? "?logout=true" : ""}`)); 31 + } 32 + 33 + if (logout) { 34 + const res = await disconnect(social, session.value); 35 + 36 + if ("status" in res) { 37 + const data = { status: 500, message: res?.message || "An error occurred" }; 38 + console.log(data); 39 + return Response.json(data); 40 + } 41 + 42 + redirect("/profile/connections"); 43 + } 44 + 45 + const code = searchParams.get("code"); 46 + 47 + if (!code) { 48 + const res = await getAuthorizeUrl(social); 49 + 50 + if ("status" in res) { 51 + const data = { status: 500, message: res?.message || "An error occurred" }; 52 + console.log(data); 53 + return Response.json(data); 54 + } 55 + 56 + if (res.verifier) { 57 + jar.set( 58 + verifier, 59 + res.verifier, 60 + { 61 + ...defaultCookieOptions, 62 + expires: new Date(Date.now() + 1000 * 600) 63 + } 64 + ); 65 + } 66 + 67 + redirect(res.url); 68 + } 69 + 70 + const res = await connect(social, session.value, code, jar.get(verifier)?.value); 71 + 72 + jar.delete(verifier); 73 + 74 + if ("status" in res) { 75 + const data = { status: 500, message: res?.message || "An error occurred" }; 76 + console.log(data); 77 + return Response.json(data); 78 + } 79 + 80 + redirect("/profile/connections?success=true"); 81 + 82 + }
+5 -1
app/login/api.ts
··· 1 1 import type { User } from "@/common/user"; 2 2 import type { ApiError } from "@/typings"; 3 + import { getCanonicalUrl } from "@/utils/urls"; 3 4 4 5 interface UserSessionCreate extends User { 5 6 session: string; ··· 15 16 const res = await fetch(`${process.env.NEXT_PUBLIC_API}/sessions`, { 16 17 method: "POST", 17 18 headers, 18 - body: JSON.stringify({ code }) 19 + body: JSON.stringify({ 20 + code, 21 + redirectUri: getCanonicalUrl("login") 22 + }) 19 23 }); 20 24 21 25 return res.json();
+1 -1
app/login/route.ts
··· 6 6 7 7 import { createSession } from "./api"; 8 8 9 - const defaultCookieOptions = { 9 + export const defaultCookieOptions = { 10 10 secure: getBaseUrl().startsWith("https://"), 11 11 httpOnly: false, 12 12 sameSite: "none",
-30
app/login/spotify/api.ts
··· 1 - import type { ApiError } from "@/typings"; 2 - 3 - export async function connectSpotify(code: string, session: string): Promise<{ success: boolean; } | ApiError> { 4 - const res = await fetch(`${process.env.NEXT_PUBLIC_API}/users/@me/connections/spotify`, { 5 - method: "PUT", 6 - headers: { 7 - "Content-Type": "application/json", 8 - Cookie: `session=${session}`, 9 - authorization: process.env.API_SECRET as string 10 - }, 11 - body: JSON.stringify({ 12 - code 13 - }) 14 - }); 15 - 16 - return res.json(); 17 - } 18 - 19 - export async function disconnectSpotify(session: string): Promise<{ success: boolean; } | ApiError> { 20 - const res = await fetch(`${process.env.NEXT_PUBLIC_API}/users/@me/connections/spotify`, { 21 - method: "DELETE", 22 - headers: { 23 - "Content-Type": "application/json", 24 - Cookie: `session=${session}`, 25 - authorization: process.env.API_SECRET as string 26 - } 27 - }); 28 - 29 - return res.json(); 30 - }
-51
app/login/spotify/route.ts
··· 1 - import { cookies } from "next/headers"; 2 - import { redirect } from "next/navigation"; 3 - 4 - import { connectSpotify, disconnectSpotify } from "./api"; 5 - 6 - export async function GET(request: Request) { 7 - const { searchParams } = new URL(request.url); 8 - const jar = await cookies(); 9 - 10 - const logout = searchParams.get("logout"); 11 - const session = jar.get("session"); 12 - 13 - if (!session?.value) { 14 - redirect("/login?callback=" + encodeURIComponent(`/login/spotify${logout === "true" ? "?logout=true" : ""}`)); 15 - } 16 - 17 - if (logout) { 18 - const res = await disconnectSpotify(session.value); 19 - 20 - if ( 21 - "status" in res && 22 - res?.status !== 401 23 - ) { 24 - const data = { status: 500, message: res?.message || "An error occurred" }; 25 - console.log(data); 26 - return Response.json(data); 27 - } 28 - 29 - redirect("/?spotify_login_success=true"); 30 - } 31 - 32 - const code = searchParams.get("code"); 33 - 34 - if (!code) { 35 - redirect(`${process.env.NEXT_PUBLIC_API}/connections/spotify`); 36 - } 37 - 38 - const res = await connectSpotify(code, session.value); 39 - 40 - if ( 41 - "status" in res && 42 - res?.status !== 401 43 - ) { 44 - const data = { status: 500, message: res?.message || "An error occurred" }; 45 - console.log(data); 46 - return Response.json(data); 47 - } 48 - 49 - redirect("/?spotify_login_success=true"); 50 - 51 - }
+118
app/profile/connections/page.tsx
··· 1 + "use client"; 2 + 3 + import Image from "next/image"; 4 + import { BsSpotify } from "react-icons/bs"; 5 + import { HiFingerPrint, HiTrash } from "react-icons/hi"; 6 + import { SiBluesky } from "react-icons/si"; 7 + import { useQuery } from "react-query"; 8 + 9 + import Notice, { NoticeType } from "@/components/notice"; 10 + import { HomeButton, ScreenMessage, SupportButton } from "@/components/screen-message"; 11 + import { Button } from "@/components/ui/button"; 12 + import { cacheOptions, getData } from "@/lib/api"; 13 + import SadWumpusPic from "@/public/sad-wumpus.gif"; 14 + import { type ApiV1UsersMeConnectionsGetResponse, ConnectionType } from "@/typings"; 15 + import { cn } from "@/utils/cn"; 16 + 17 + const CONNECTION_TYPES = Object 18 + .entries(ConnectionType) 19 + .filter(([key, value]) => typeof key === "string" && typeof value === "number") as [string, ConnectionType][]; 20 + 21 + export default function Home() { 22 + const url = "/users/@me/connections" as const; 23 + 24 + const { isLoading, data, error } = useQuery( 25 + url, 26 + () => getData<ApiV1UsersMeConnectionsGetResponse[]>(url), 27 + cacheOptions 28 + ); 29 + 30 + if (error || (data && "message" in data)) { 31 + return ( 32 + <ScreenMessage 33 + title="Something went wrong on this page.." 34 + description={ 35 + (data && "message" in data ? data.message : `${error}`) 36 + || "An unknown error occurred."} 37 + buttons={<> 38 + <HomeButton /> 39 + <SupportButton /> 40 + </>} 41 + > 42 + <Image src={SadWumpusPic} alt="" height={141} width={124} /> 43 + </ScreenMessage> 44 + ); 45 + } 46 + 47 + if (isLoading || !data) return <></>; 48 + 49 + return ( 50 + <div className="space-y-2"> 51 + <Notice 52 + type={NoticeType.Info} 53 + message="This is still in testing, don&apos;t expect it to work properly yet!" 54 + /> 55 + 56 + {CONNECTION_TYPES.map(([name, type]) => ( 57 + <Connection key={name} name={name} type={type} data={data} /> 58 + ))} 59 + </div> 60 + ); 61 + } 62 + 63 + function Connection( 64 + { name, type, data }: 65 + { name: string; type: ConnectionType; data: ApiV1UsersMeConnectionsGetResponse[]; } 66 + ) { 67 + const connection = data.find((entry) => entry.type === type); 68 + 69 + return ( 70 + <div 71 + key={name} 72 + className="flex gap-3 justify-between p-4 bg-wamellow rounded-xl w-full" 73 + > 74 + {connection 75 + ? <Image 76 + alt={`user's ${name} avatar`} 77 + className="rounded-full size-12 shrink-0" 78 + height={56} 79 + src={connection?.avatar || "/discord.webp"} 80 + width={56} 81 + /> 82 + : ( 83 + <div className="bg-wamellow-100 rounded-full size-12 flex items-center justify-center text-neutral-300 shrink-0"> 84 + <Icon type={type} /> 85 + </div> 86 + ) 87 + } 88 + 89 + <div className="truncate"> 90 + <span className={cn("font-medium text-lg text-red-400 ", connection && "text-neutral-100")}> 91 + {connection?.username || "Not connected"} 92 + </span> 93 + <div className="flex items-center gap-1"> 94 + <Icon type={type} /> 95 + {name} 96 + </div> 97 + </div> 98 + 99 + <Button 100 + className="ml-auto" 101 + onClick={() => { 102 + // idk with router.push logout doesn't update 103 + window.location.href = `/login/${name.toLowerCase()}${connection ? "?logout=true" : ""}`; 104 + }} 105 + > 106 + {connection ? <HiTrash /> : <HiFingerPrint />} 107 + {connection ? "Disconnect" : "Connect"} 108 + </Button> 109 + </div> 110 + ); 111 + } 112 + 113 + function Icon({ type }: { type: ConnectionType; }) { 114 + switch (type) { 115 + case ConnectionType.Spotify: return <BsSpotify />; 116 + case ConnectionType.Bluesky: return <SiBluesky />; 117 + } 118 + }
+4 -4
app/profile/layout.tsx
··· 7 7 import { useCookies } from "next-client-cookies"; 8 8 import { Suspense } from "react"; 9 9 import CountUp from "react-countup"; 10 - import { HiChartPie, HiFire, HiHome, HiMusicNote, HiPhotograph, HiTranslate } from "react-icons/hi"; 10 + import { HiChartPie, HiCubeTransparent, HiFire, HiHome, HiPhotograph, HiTranslate } from "react-icons/hi"; 11 11 import { useQuery } from "react-query"; 12 12 13 13 import { userStore } from "@/common/user"; ··· 144 144 icon: <HiTranslate /> 145 145 }, 146 146 { 147 - name: "Spotify", 148 - value: "/spotify", 149 - icon: <HiMusicNote /> 147 + name: "Connections", 148 + value: "/connections", 149 + icon: <HiCubeTransparent /> 150 150 }, 151 151 ...(user?.HELLO_AND_WELCOME_TO_THE_DEV_TOOLS__PLEASE_GO_AWAY ? 152 152 [
-186
app/profile/spotify/page.tsx
··· 1 - "use client"; 2 - 3 - import Image from "next/image"; 4 - import Link from "next/link"; 5 - import { use } from "react"; 6 - import { BsSpotify } from "react-icons/bs"; 7 - import { useQuery } from "react-query"; 8 - 9 - import { userStore } from "@/common/user"; 10 - import Box from "@/components/box"; 11 - import { DiscordMarkdown } from "@/components/discord/markdown"; 12 - import DiscordMessage from "@/components/discord/message"; 13 - import { HomeButton, ScreenMessage, SupportButton } from "@/components/screen-message"; 14 - import { cacheOptions, getData } from "@/lib/api"; 15 - import SadWumpusPic from "@/public/sad-wumpus.gif"; 16 - import type { ApiV1UsersMeConnectionsSpotifyGetResponse } from "@/typings"; 17 - 18 - interface Props { 19 - searchParams: Promise<{ spotify_login_success?: string; }>; 20 - } 21 - 22 - export default function Home({ searchParams }: Props) { 23 - const search = use(searchParams); 24 - const user = userStore((s) => s); 25 - 26 - const url = "/users/@me/connections/spotify" as const; 27 - 28 - const { isLoading, data, error } = useQuery( 29 - url, 30 - () => getData<ApiV1UsersMeConnectionsSpotifyGetResponse>(url), 31 - cacheOptions 32 - ); 33 - 34 - if (error || (data && "message" in data && data.status !== 404)) { 35 - return ( 36 - <ScreenMessage 37 - title="Something went wrong on this page.." 38 - description={ 39 - (data && "message" in data ? data.message : `${error}`) 40 - || "An unknown error occurred."} 41 - buttons={<> 42 - <HomeButton /> 43 - <SupportButton /> 44 - </>} 45 - > 46 - <Image src={SadWumpusPic} alt="" height={141} width={124} /> 47 - </ScreenMessage> 48 - ); 49 - } 50 - 51 - if (isLoading || !data) return <></>; 52 - 53 - return ( 54 - <div className="h-full"> 55 - 56 - {"status" in data && 57 - <ScreenMessage 58 - title="Nothing to see here.. yet.." 59 - description="Cool things will come soon" 60 - className="bg-[#1ed760] hover:bg-[#1ed760]/80 text-black cursor-not-allowed opacity-50" 61 - href="/login/spotify" 62 - icon={<BsSpotify />} 63 - button="Connect Spotify" 64 - /> 65 - } 66 - 67 - {"displayName" in data && user?.id && 68 - <> 69 - 70 - <div className="flex items-center gap-2"> 71 - {/* eslint-disable-next-line @next/next/no-img-element */} 72 - <img src={data.avatar ? data.avatar : "/discord.webp"} alt="your spotify avatar" className="rounded-lg mr-1 h-14 w-14" /> 73 - <div> 74 - <div className="text-2xl dark:text-neutral-200 text-neutral-800 font-medium flex gap-1 items-center"> 75 - {data.displayName} 76 - <BsSpotify className="h-4 relative top-0.5 text-[#1ed760]" /> 77 - </div> 78 - <div className="flex items-center"> 79 - <Link 80 - className="text-violet-500 opacity-60 hover:opacity-80 duration-200" 81 - href="/login/spotify?logout=true" 82 - prefetch={false} 83 - > 84 - Not you? 85 - </Link> 86 - {search.spotify_login_success === "true" && data.displayName && <> 87 - <span className="mx-2 text-neutral-500">•</span> 88 - <div className="text-green-500 duration-200">Link was successfull!</div> 89 - </>} 90 - </div> 91 - </div> 92 - </div> 93 - 94 - <div className="w-full border-b dark:border-wamellow-light border-wamellow-100-light md:hidden mt-6" /> 95 - 96 - <Box className="mt-6 flex flex-col gap-6 overflow-hidden" small> 97 - 98 - <DiscordMessage 99 - mode={"DARK"} 100 - user={{ 101 - username: user.globalName || user.username, 102 - avatar: user.avatar ? `https://cdn.discordapp.com/avatars/821472922140803112/${user.avatar}.webp?size=64` : "/discord.webp", 103 - bot: false 104 - }} 105 - > 106 - 107 - <DiscordMarkdown mode={"DARK"} text={`wm play [https://open.spotify.com/track/${data.playing?.id || "4cOdK2wGLETKBW3PvgPWqT"}](#)`} /> 108 - 109 - </DiscordMessage> 110 - <DiscordMessage 111 - mode={"DARK"} 112 - user={{ 113 - username: "Wamellow", 114 - avatar: "/waya-v3.webp", 115 - bot: true 116 - }} 117 - > 118 - 119 - <div className="flex items-center gap-1"> 120 - <Image src="https://cdn.discordapp.com/emojis/845043307351900183.gif?size=44&quality=lossless" height={18} width={18} alt="" /> 121 - <DiscordMarkdown mode={"DARK"} text={`@${user.username} now playing [${data.playing?.name || "Never Gonna Give You Up"}](#) for **${data.playing?.duration || "3 minutes 33 seconds"}**`} /> 122 - </div> 123 - 124 - <div className="flex flex-row gap-1.5 h-8 mt-3"> 125 - <div className="dark:border-neutral-600/90 border-neutral-400/90 border-2 h-full w-32 py-2.5 px-4 rounded-md flex items-center justify-center cursor-pointer"> 126 - <div className="dark:bg-neutral-600/90 bg-neutral-400/90 h-full w-full rounded-full" /> 127 - </div> 128 - <div className="dark:border-neutral-600/90 border-neutral-400/90 border-2 h-full w-16 py-2.5 px-4 rounded-md flex items-center justify-center cursor-pointer"> 129 - <div className="dark:bg-neutral-600/90 bg-neutral-400/90 h-full w-full rounded-full" /> 130 - </div> 131 - <div className="dark:border-neutral-600/90 border-neutral-400/90 border-2 h-full w-16 py-2.5 px-4 rounded-md flex items-center justify-center cursor-pointer"> 132 - <div className="dark:bg-neutral-600/90 bg-neutral-400/90 h-full w-full rounded-full" /> 133 - </div> 134 - </div> 135 - 136 - </DiscordMessage> 137 - 138 - <DiscordMessage 139 - mode={"DARK"} 140 - user={{ 141 - username: user.globalName || user.username, 142 - avatar: user.avatar ? `https://cdn.discordapp.com/avatars/821472922140803112/${user.avatar}.webp?size=64` : "/discord.webp", 143 - bot: false 144 - }} 145 - > 146 - 147 - <DiscordMarkdown mode={"DARK"} text="wm" /> 148 - 149 - </DiscordMessage> 150 - <DiscordMessage 151 - mode={"DARK"} 152 - user={{ 153 - username: "Wamellow", 154 - avatar: "/waya-v3.webp", 155 - bot: true 156 - }} 157 - > 158 - 159 - <div className="flex items-center gap-1"> 160 - <Image src="https://cdn.discordapp.com/emojis/845043307351900183.gif?size=44&quality=lossless" height={18} width={18} alt="" /> 161 - <DiscordMarkdown mode={"DARK"} text={`@${user.username} is playing [${data.playing?.name || "Never Gonna Give You Up"}](#) by ${data.playing?.artists || "[Rick Astley]()"}`} /> 162 - </div> 163 - 164 - <div className="flex gap-1.5 h-8 mt-3"> 165 - <div className="dark:border-neutral-700/90 border-neutral-300/90 border-2 h-full w-32 py-2.5 px-4 rounded-md flex items-center justify-center cursor-pointer"> 166 - <div className="dark:bg-neutral-700/90 bg-neutral-300/90 h-full w-full rounded-full" /> 167 - </div> 168 - <div className="dark:border-neutral-700/90 border-neutral-300/90 border-2 h-full w-16 py-2.5 px-4 rounded-md flex items-center justify-center cursor-pointer"> 169 - <div className="dark:bg-neutral-700/90 bg-neutral-300/90 h-full w-full rounded-full" /> 170 - </div> 171 - <div className="dark:border-neutral-700/90 border-neutral-300/90 border-2 h-full w-16 py-2.5 px-4 rounded-md flex items-center justify-center cursor-pointer"> 172 - <div className="dark:bg-neutral-700/90 bg-neutral-300/90 h-full w-full rounded-full" /> 173 - </div> 174 - </div> 175 - 176 - </DiscordMessage> 177 - 178 - </Box> 179 - 180 - </> 181 - } 182 - 183 - 184 - </div> 185 - ); 186 - }
+12 -12
next.config.js
··· 49 49 port: "", 50 50 pathname: "/**" 51 51 }, 52 - { 53 - protocol: "https", 54 - hostname: "ai.local.wamellow.com", 55 - port: "", 56 - pathname: "/static/**" 57 - }, 58 52 59 53 { 60 54 protocol: "https", ··· 68 62 port: "", 69 63 pathname: "/jtv_user_pictures/**" 70 64 }, 71 - { 72 - protocol: "https", 73 - hostname: "cdn.bsky.app", 74 - port: "", 75 - pathname: "/img/avatar/plain/**" 76 - } 65 + { 66 + protocol: "https", 67 + hostname: "cdn.bsky.app", 68 + port: "", 69 + pathname: "/img/avatar/plain/**" 70 + }, 71 + { 72 + protocol: "https", 73 + hostname: "i.scdn.co", 74 + port: "", 75 + pathname: "/image/**" 76 + } 77 77 ] 78 78 } 79 79 };
+8 -8
typings.ts
··· 299 299 }; 300 300 } 301 301 302 - export interface ApiV1UsersMeConnectionsSpotifyGetResponse { 303 - displayName: string; 302 + export enum ConnectionType { 303 + Spotify = 0, 304 + Bluesky = 1 305 + } 306 + 307 + export interface ApiV1UsersMeConnectionsGetResponse { 308 + username: string; 304 309 avatar: string | null; 305 - playing: { 306 - name: string; 307 - id: string; 308 - artists: string; 309 - duration: string; 310 - } | undefined; 310 + type: ConnectionType; 311 311 } 312 312 313 313 export interface ApiV1GuildsModulesTagsGetResponse {