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 custom notification styles

Luna 45f7c928 c86942fc

+295 -21
+3 -3
app/(home)/premium/page.tsx
··· 27 27 const handwritten = Patrick_Hand({ subsets: ["latin"], weight: "400" }); 28 28 29 29 const bots = ["1125449347451068437", "985213199248924722", "1097907896987160666"].map((userId) => getUser(userId)); 30 - const maybe = null; 31 30 32 31 const items = [ 33 32 { title: "Price", free: "€0 /forever", premium: "€4 /month" }, ··· 38 37 { title: "Bypass voting", free: false, premium: true, tooltip: <OtherBotsTooltip /> }, 39 38 { title: "Bypass passport", free: false, premium: true }, 40 39 { title: "Premium role", free: false, premium: true, url: "/support" }, 41 - { title: "Spotify control", free: maybe, premium: true, url: "/profile/connections" }, 40 + // { title: "Spotify control", free: maybe, premium: true, url: "/profile/connections" }, 42 41 { title: "Fast support", free: true, premium: true }, 43 42 44 43 { title: "For Your Server", icon: <HiUserGroup /> }, ··· 47 46 { title: "Dailyposts", free: 4, premium: 20 }, 48 47 { title: "Welcome pings", free: 5, premium: 15 }, 49 48 { title: "Welcome roles", free: 5, premium: 10 }, 50 - { title: "Crosspost notifications", free: false, premium: true } 49 + { title: "Notification styles", free: false, premium: true }, 50 + { title: "Notification crosspost", free: false, premium: true } 51 51 ]; 52 52 53 53 export const revalidate = 3600;
+1 -1
app/dashboard/[guildId]/notifications/create-reddit.component.tsx
··· 89 89 }} 90 90 /> 91 91 92 - <Section tight title="How to get a users name"> 92 + <Section tight title="How to get a subreddit name"> 93 93 The name is the string with the leading <code className="break-all">r/</code>, such as <code>r/wamellow</code>. 94 94 <br /> 95 95 <br />
+3 -4
app/dashboard/[guildId]/notifications/create-twitch.component.tsx
··· 7 7 import DumbTextInput from "@/components/inputs/dumb-text-input"; 8 8 import SelectMenu from "@/components/inputs/select-menu"; 9 9 import Modal from "@/components/modal"; 10 + import { Section } from "@/components/section"; 10 11 import TutorialPic from "@/public/docs-assets/notifications-channel-urls.webp"; 11 12 import { type ApiV1GuildsModulesNotificationsGetResponse, NotificationType } from "@/typings"; 12 13 import { createSelectableItems } from "@/utils/create-selectable-items"; ··· 90 91 }} 91 92 /> 92 93 93 - <div className="mt-4"> 94 - <span className="text-lg dark:text-neutral-300 text-neutral-700 font-medium">How to get a streamer&apos;s username</span> 94 + <Section tight title="How to get a usersname"> 95 95 <Image 96 96 alt="How to get a Creator's @handle, id or URL" 97 97 className="rounded-md" 98 98 src={TutorialPic} 99 99 /> 100 - </div> 101 - 100 + </Section> 102 101 </Modal> 103 102 </>); 104 103 }
+3 -4
app/dashboard/[guildId]/notifications/create-youtube.component.tsx
··· 7 7 import DumbTextInput from "@/components/inputs/dumb-text-input"; 8 8 import SelectMenu from "@/components/inputs/select-menu"; 9 9 import Modal from "@/components/modal"; 10 + import { Section } from "@/components/section"; 10 11 import TutorialPic from "@/public/docs-assets/notifications-channel-urls.webp"; 11 12 import { type ApiV1GuildsModulesNotificationsGetResponse, NotificationType } from "@/typings"; 12 13 import { createSelectableItems } from "@/utils/create-selectable-items"; ··· 99 100 }} 100 101 /> 101 102 102 - <div className="mt-4"> 103 - <span className="text-lg dark:text-neutral-300 text-neutral-700 font-medium">How to get a channel&apos;s @handle or Id</span> 103 + <Section tight title="How to get a usersname"> 104 104 <Image 105 105 alt="How to get a Creator's @handle, id or URL" 106 106 className="rounded-md" 107 107 src={TutorialPic} 108 108 /> 109 - </div> 110 - 109 + </Section> 111 110 </Modal> 112 111 </>); 113 112 }
+18 -1
app/dashboard/[guildId]/notifications/page.tsx
··· 21 21 import { ScreenMessage } from "@/components/screen-message"; 22 22 import { Button } from "@/components/ui/button"; 23 23 import { cacheOptions } from "@/lib/api"; 24 - import { type ApiV1GuildsModulesNotificationsGetResponse, NotificationFlags, NotificationType } from "@/typings"; 24 + import { type ApiV1GuildsModulesNotificationsGetResponse, GuildFlags, NotificationFlags, NotificationType } from "@/typings"; 25 25 import { BitfieldManager, bitfieldToArray } from "@/utils/bitfields"; 26 26 import { createSelectableItems } from "@/utils/create-selectable-items"; 27 27 import { getCanonicalUrl } from "@/utils/urls"; ··· 29 29 import { hasBlueskyPost } from "./api"; 30 30 import { DeleteNotification } from "./delete.component"; 31 31 import { CreateNotificationSelect, Icon, Style } from "./select.component"; 32 + import { NotificationStyle } from "./style.component"; 32 33 33 34 export default function Home() { 34 35 const guild = guildStore((g) => g); 35 36 const params = useParams(); 37 + 38 + const premium = ((guild?.flags || 0) & GuildFlags.Premium) === GuildFlags.Premium; 36 39 37 40 const url = `/guilds/${params.guildId}/modules/notifications` as const; 38 41 const { ··· 240 243 /> 241 244 )} 242 245 246 + <NotificationStyle 247 + item={item} 248 + premium={premium} 249 + onEdit={(style) => editItem("style", style)} 250 + /> 251 + 243 252 <MessageCreatorEmbed 244 253 key={item.id} 245 254 name="Message" 246 255 url={url + "/" + item.id} 247 256 dataName="message" 248 257 defaultMessage={item.message} 258 + user={premium && item.style 259 + ? { 260 + username: item.style.name || "", 261 + avatar: item.style.avatarUrl || "/discord.webp", 262 + bot: true 263 + } 264 + : undefined 265 + } 249 266 onSave={(value) => editItem("message", { content: value.content ?? null, embed: value.embed })} 250 267 /> 251 268 </>);
+243
app/dashboard/[guildId]/notifications/style.component.tsx
··· 1 + "use client"; 2 + import Link from "next/link"; 3 + import { useRef, useState } from "react"; 4 + import { HiOutlineUpload, HiPencil, HiSparkles, HiX } from "react-icons/hi"; 5 + 6 + import { guildStore } from "@/common/guilds"; 7 + import DiscordMessage from "@/components/discord/message"; 8 + import DumbTextInput from "@/components/inputs/dumb-text-input"; 9 + import Modal from "@/components/modal"; 10 + import { Section } from "@/components/section"; 11 + import { UserAvatar } from "@/components/ui/avatar"; 12 + import { Button } from "@/components/ui/button"; 13 + import type { ApiV1GuildsModulesNotificationsGetResponse } from "@/typings"; 14 + import { cn } from "@/utils/cn"; 15 + 16 + const ALLOWED_FILE_TYPES = ["image/png", "image/jpeg", "image/webp"]; 17 + const MAX_FILE_SIZE = 8 * 1024 * 1024; 18 + 19 + export function NotificationStyle({ 20 + item, 21 + premium, 22 + onEdit 23 + }: { 24 + item: ApiV1GuildsModulesNotificationsGetResponse; 25 + premium: boolean; 26 + onEdit: (opts: { name: string; avatarUrl: string | null; }) => void; 27 + }) { 28 + const [open, setOpen] = useState(false); 29 + 30 + return (<> 31 + <div className="w-full relative overflow-hidden rounded-lg border border-border group p-px mt-5"> 32 + <span className="absolute inset-[-1000%] animate-[spin_5s_linear_infinite_reverse] bg-[conic-gradient(from_90deg_at_0%_50%,#8b5cf6_50%,var(--wamellow-rgb)_100%)]" /> 33 + 34 + <div className="backdrop-blur-3xl backdrop-brightness-[25%] rounded-[6px] pr-4 py-4 pl-6 md:py-8 md:pl-10 flex gap-6 items-center"> 35 + <UserAvatar 36 + alt={premium && item.style ? (item.style.name || "") : "Wamellow"} 37 + className="size-24" 38 + src={premium && item.style ? (item.style.avatarUrl || "/discord.webp") : "/waya-v3.webp"} 39 + /> 40 + 41 + <div className="space-y-2"> 42 + <span className="text-3xl font-medium text-primary-foreground"> 43 + {premium && item.style ? (item.style.name || "Unknown") : "Wamellow"} 44 + </span> 45 + {premium 46 + ? <Button onClick={() => setOpen(true)}> 47 + <HiPencil /> 48 + Change Style 49 + </Button> 50 + : <Button asChild> 51 + <Link 52 + href={`/premium?utm_source=${window.location.hostname}&utm_medium=notification-styles`} 53 + target="_blank" 54 + > 55 + <HiSparkles /> 56 + Change Style 57 + </Link> 58 + </Button> 59 + } 60 + </div> 61 + 62 + <ExampleMessages /> 63 + </div> 64 + </div> 65 + 66 + <ChangeStyleModal 67 + id={item.id} 68 + username={item.style?.name || null} 69 + avatarUrl={item.style?.avatarUrl || null} 70 + 71 + isOpen={open} 72 + onClose={() => setOpen(false)} 73 + onEdit={(style) => onEdit(style)} 74 + /> 75 + </>); 76 + } 77 + 78 + function ExampleMessages() { 79 + return ( 80 + <div className="w-full relative"> 81 + <ExampleMessage className="bottom-0 right-0 rotate-2" username="Kurzgesagt" avatarUrl="https://yt3.googleusercontent.com/ytc/AIdro_n1Ribd7LwdP_qKtqWL3ZDfIgv9M1d6g78VwpHGXVR2Ir4=s176-c-k-c0x00ffffff-no-rj-mo" /> 82 + <ExampleMessage className="top-0 right-0" username="DarkViperAU" avatarUrl="https://yt3.googleusercontent.com/ytc/AIdro_lpNK9jpdw9D63LuUYt3SLbFpIQ5yD4DV0D5mwPrCp7cEw=s176-c-k-c0x00ffffff-no-rj-mo" /> 83 + </div> 84 + ); 85 + } 86 + 87 + function ExampleMessage({ className, username, avatarUrl }: { className?: string; username?: string; avatarUrl?: string; }) { 88 + return ( 89 + <div className={cn("bg-discord-gray px-3 py-2 rounded-lg w-full max-w-sm absolute border border-wamellow shadow-lg", className)}> 90 + <DiscordMessage 91 + mode="DARK" 92 + user={{ 93 + username: username || "Wamellow", 94 + avatar: avatarUrl || "/waya-v3.webp", 95 + bot: true 96 + }} 97 + > 98 + Woooooooo! 99 + </DiscordMessage> 100 + </div> 101 + ); 102 + } 103 + 104 + function isValidUsername(value: string | null): boolean { 105 + if (!value) return false; 106 + if (value.length < 2 || value.length > 32) return false; 107 + if (["everyone", "here"].includes(value.toLowerCase())) return false; 108 + if (/@|#|:|```|discord|clyde/i.test(value)) return false; 109 + return true; 110 + } 111 + 112 + interface Props { 113 + id: string; 114 + username: string | null; 115 + avatarUrl: string | null; 116 + 117 + isOpen: boolean; 118 + onClose: () => void; 119 + onEdit: (opts: { name: string; avatarUrl: string | null; }) => void; 120 + } 121 + 122 + export function ChangeStyleModal({ 123 + id, 124 + username, 125 + avatarUrl, 126 + 127 + isOpen, 128 + onClose, 129 + onEdit 130 + }: Props) { 131 + const guildId = guildStore((g) => g?.id); 132 + const avatarRef = useRef<HTMLInputElement | null>(null); 133 + 134 + const [name, setName] = useState(username); 135 + const [avatar, setAvatar] = useState<string | null>(avatarUrl); 136 + const [error, setError] = useState<string | null>(null); 137 + 138 + return (<> 139 + <Modal<ApiV1GuildsModulesNotificationsGetResponse> 140 + title="Edit Notification Style" 141 + isOpen={isOpen} 142 + onClose={onClose} 143 + onSubmit={() => { 144 + const valid = isValidUsername(name); 145 + if (!valid) return new Error("Invalid name"); 146 + 147 + return fetch(`${process.env.NEXT_PUBLIC_API}/guilds/${guildId}/modules/notifications/${id}/style`, { 148 + method: "PATCH", 149 + credentials: "include", 150 + headers: { 151 + "Content-Type": "application/json" 152 + }, 153 + body: JSON.stringify({ 154 + name, 155 + avatar: avatar?.startsWith("data:") ? avatar : null 156 + }) 157 + }); 158 + }} 159 + onSuccess={() => { 160 + onEdit({ name: name!, avatarUrl: avatar! }); 161 + }} 162 + isDisabled={!name || Boolean(error)} 163 + > 164 + <DumbTextInput 165 + name="Username" 166 + placeholder="DarkViperAU" 167 + value={name} 168 + setValue={setName} 169 + /> 170 + 171 + <input 172 + accept={ALLOWED_FILE_TYPES.join()} 173 + className="hidden" 174 + onChange={(e) => { 175 + setAvatar(null); 176 + 177 + const file = e.target.files?.[0]; 178 + if (!file) return; 179 + 180 + if (!ALLOWED_FILE_TYPES.includes(file.type)) { 181 + setError(`File type must be one of ${ALLOWED_FILE_TYPES.join(", ")}`); 182 + return; 183 + } 184 + 185 + if (file.size > MAX_FILE_SIZE) { 186 + setError(`File size must be less than ${MAX_FILE_SIZE / 1024 / 1024}MiB`); 187 + return; 188 + } 189 + 190 + const reader = new FileReader(); 191 + 192 + reader.onload = () => { 193 + if (typeof reader.result === "string") { 194 + setAvatar(reader.result); 195 + } 196 + }; 197 + 198 + reader.onerror = () => { 199 + setError("Failed to read the file."); 200 + }; 201 + 202 + reader.readAsDataURL(file); 203 + }} 204 + ref={avatarRef} 205 + type="file" 206 + /> 207 + 208 + <div className="flex"> 209 + <Button onClick={() => avatarRef.current?.click()}> 210 + <HiOutlineUpload /> 211 + Upload Avatar 212 + </Button> 213 + {avatar && ( 214 + <Button 215 + className="text-red-400" 216 + variant="link" 217 + onClick={() => setAvatar(null)} 218 + > 219 + <HiX /> 220 + Remove Avatar 221 + </Button> 222 + )} 223 + </div> 224 + {error && <p className="text-red-500 text-sm mt-1">{error}</p>} 225 + 226 + <Section tight title="Preview"> 227 + <div className="bg-discord-gray px-3 py-2 mt-1 rounded-lg w-full"> 228 + <DiscordMessage 229 + mode="DARK" 230 + user={{ 231 + username: name || "Wamellow", 232 + avatar: avatar || "/waya-v3.webp", 233 + bot: true 234 + }} 235 + > 236 + Woooooooo! 237 + </DiscordMessage> 238 + </div> 239 + </Section> 240 + 241 + </Modal> 242 + </>); 243 + }
+9 -3
components/embed-creator.tsx
··· 32 32 messageAttachmentComponent?: React.ReactNode; 33 33 showMessageAttachmentComponentInEmbed?: boolean; 34 34 35 + user?: { 36 + username: string; 37 + avatar: string; 38 + bot: boolean; 39 + }; 40 + 35 41 disabled?: boolean; 36 42 onSave?: (state: { content?: string | null; embed?: GuildEmbed; }) => void; 37 43 } ··· 48 54 49 55 messageAttachmentComponent, 50 56 showMessageAttachmentComponentInEmbed, 57 + 58 + user, 51 59 52 60 disabled, 53 61 onSave ··· 231 239 232 240 <DiscordMessage 233 241 mode={mode} 234 - user={{ 242 + user={user || { 235 243 username: "Wamellow", 236 244 avatar: "/waya-v3.webp", 237 245 bot: true ··· 255 263 </DiscordMessageEmbed> 256 264 257 265 {!showMessageAttachmentComponentInEmbed && messageAttachmentComponent} 258 - 259 266 </DiscordMessage> 260 - 261 267 </div> 262 268 263 269 </div>
+1 -1
components/inputs/dumb-text-input.tsx
··· 31 31 dataName 32 32 }: Props) { 33 33 const className = cn( 34 - "mt-1 resize-y w-full dark:bg-wamellow bg-wamellow-100 rounded-lg flex items-center px-4 py-2 focus:outline outline-violet-400 caret-violet-400 outline-2", 34 + "resize-y w-full dark:bg-wamellow bg-wamellow-100 rounded-lg flex items-center px-3.5 py-2 focus:outline outline-violet-400 caret-violet-400 outline-2", 35 35 max > 300 ? "h-28" : (thin ? "h-10" : "h-12"), 36 36 thin && "relative bottom-1", 37 37 disabled && "cursor-not-allowed opacity-50"
+2 -2
components/inputs/text-input.tsx
··· 18 18 interface Props { 19 19 className?: string; 20 20 21 - name: string; 21 + name?: string; 22 22 url?: string; 23 23 dataName?: string; 24 24 disabled?: boolean; ··· 119 119 return ( 120 120 <div className={cn("relative w-full", className)}> 121 121 122 - <div className="flex items-center gap-2"> 122 + <div className="flex items-center gap-2 mb-1"> 123 123 <span className="text-lg dark:text-neutral-300 text-neutral-700 font-medium">{name}</span> 124 124 {state === State.Loading && <TailSpin stroke="#d4d4d4" strokeWidth={8} className="relative h-3 w-3 overflow-visible" />} 125 125
+2 -2
components/modal.tsx
··· 169 169 size="sm" 170 170 > 171 171 <Link 172 - href={`/premium?utm_source=${window.location.hostname}&utm_medium=modal`} 172 + href={`/premium?utm_source=${window.location.hostname}&utm_medium=notification-limit`} 173 173 target="_blank" 174 174 > 175 175 Upgrade ··· 179 179 180 180 <div className="absolute -top-2 -right-0.5 z-10"> 181 181 <Badge className="rotate-3 backdrop-blur-md backdrop-brightness-75"> 182 - First Month Free!! 182 + 4€/month!! 183 183 </Badge> 184 184 </div> 185 185 </div>
+10
typings.ts
··· 23 23 bot: boolean; 24 24 } 25 25 26 + export enum GuildFlags { 27 + Premium = 1 << 0, 28 + } 29 + 26 30 export interface ApiV1GuildsGetResponse { 27 31 id: string; 28 32 name: string; ··· 45 49 queue: boolean | null; 46 50 }; 47 51 embedLinks: boolean; 52 + flags: number; 48 53 } 49 54 50 55 export interface ApiV1GuildsTopmembersGetResponse { ··· 460 465 customUrl: string; 461 466 avatarUrl: string | null; 462 467 }; 468 + 469 + style: { 470 + name: string | null; 471 + avatarUrl: string | null; 472 + } | null 463 473 } 464 474 465 475 export enum DailypostType {