Your music, beautifully tracked. All yours. (coming soon) teal.fm
teal-fm atproto

clean up styles, add settings

+502 -91
+12 -1
apps/amethyst/app/(tabs)/(stamp)/_layout.tsx
··· 21 21 } 22 22 }, [segment]); 23 23 24 - return <Stack screenOptions={{ headerShown: false }}>{rootScreen}</Stack>; 24 + return ( 25 + <Stack 26 + screenOptions={{ 27 + headerShown: false, 28 + headerStyle: { 29 + height: 50, 30 + } as any, 31 + }} 32 + > 33 + {rootScreen} 34 + </Stack> 35 + ); 25 36 }; 26 37 27 38 export default Layout;
+7 -1
apps/amethyst/app/(tabs)/(stamp)/stamp/_layout.tsx
··· 27 27 }); 28 28 return ( 29 29 <StampContext.Provider value={{ state, setState }}> 30 - <Stack> 30 + <Stack 31 + screenOptions={{ 32 + headerStyle: { 33 + height: 50, 34 + } as any, 35 + }} 36 + > 31 37 <Slot /> 32 38 </Stack> 33 39 </StampContext.Provider>
+94 -10
apps/amethyst/app/(tabs)/(stamp)/stamp/index.tsx
··· 1 1 import { Button } from "@/components/ui/button"; 2 2 import { Icon } from "@/lib/icons/iconWithClassName"; 3 - import { Stack, useRouter } from "expo-router"; 3 + import { Link, Stack, useRouter } from "expo-router"; 4 4 import { Check, ChevronDown, ChevronRight } from "lucide-react-native"; 5 5 6 6 import React, { useContext, useEffect, useRef, useState } from "react"; ··· 16 16 import { Text } from "@/components/ui/text"; 17 17 import { 18 18 MusicBrainzRecording, 19 + MusicBrainzRelease, 19 20 ReleaseSelections, 20 21 searchMusicbrainz, 21 22 SearchParams, ··· 24 25 import { BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; 25 26 import SheetBackdrop, { SheetHandle } from "@/components/ui/sheetBackdrop"; 26 27 import { StampContext, StampContextValue, StampStep } from "./_layout"; 28 + import { ExternalLink } from "@/components/ExternalLink"; 27 29 28 30 export default function StepOne() { 29 31 const router = useRouter(); ··· 45 47 {}, 46 48 ); 47 49 50 + const [hasSearched, setHasSearched] = useState<boolean>(false); 51 + 48 52 // reset search state if requested 49 53 useEffect(() => { 50 54 if (state.step === StampStep.IDLE && state.resetSearchState) { ··· 65 69 const results = await searchMusicbrainz(searchFields); 66 70 setSearchResults(results); 67 71 setIsLoading(false); 72 + setHasSearched(true); 68 73 }; 69 74 70 75 const clearSearch = () => { ··· 74 79 }; 75 80 76 81 return ( 77 - <ScrollView className="flex-1 p-4 bg-background items-center"> 82 + <ScrollView className="flex-1 justify-start items-center w-min bg-background pt-2"> 78 83 <Stack.Screen 79 84 options={{ 80 85 title: "Stamp a play manually", ··· 82 87 }} 83 88 /> 84 89 {/* Search Form */} 85 - <View className="flex gap-4 max-w-screen-md w-screen px-4"> 90 + <View className="flex gap-4 max-w-2xl w-screen px-4"> 86 91 <Text className="font-bold text-lg">Search for a track</Text> 87 92 <TextInput 88 93 className="p-2 border rounded-lg border-gray-300 bg-white" ··· 110 115 } 111 116 }} 112 117 /> 118 + <TextInput 119 + className="p-2 border rounded-lg border-gray-300 bg-white" 120 + placeholder="Album name..." 121 + value={searchFields.release} 122 + onChangeText={(text) => 123 + setSearchFields((prev) => ({ ...prev, release: text })) 124 + } 125 + onKeyPress={(e) => { 126 + if (e.nativeEvent.key === "Enter") { 127 + handleSearch(); 128 + } 129 + }} 130 + /> 113 131 <View className="flex-row gap-2"> 114 132 <Button 115 133 className="flex-1" ··· 130 148 </View> 131 149 132 150 {/* Search Results */} 133 - <View className="flex gap-4 max-w-screen-md w-screen px-4"> 134 - {searchResults.length > 0 && ( 151 + <View className="flex gap-4 max-w-2xl w-screen px-4"> 152 + {searchResults.length > 0 ? ( 135 153 <View className="mt-4"> 136 154 <Text className="text-lg font-bold mb-2"> 137 155 Search Results ({searchResults.length}) 138 156 </Text> 157 + 139 158 <FlatList 140 159 data={searchResults} 141 160 renderItem={({ item }) => ( ··· 155 174 keyExtractor={(item) => item.id} 156 175 /> 157 176 </View> 177 + ) : ( 178 + hasSearched && ( 179 + <View className="mt-4"> 180 + <Text className="text-lg text-muted-foreground mb-2 text-center"> 181 + No search results found. 182 + </Text> 183 + <Text className="text-lg text-muted-foreground mb-2 text-center"> 184 + Please try importing with{" "} 185 + <ExternalLink 186 + href="https://harmony.pulsewidth.org.uk/" 187 + className="border-b border-muted-foreground/60 text-bsky" 188 + > 189 + Harmony 190 + </ExternalLink>{" "} 191 + or manually on{" "} 192 + <ExternalLink 193 + href="https://musicbrainz.org/release/add" 194 + className="border-b border-muted-foreground/60 text-bsky" 195 + > 196 + Musicbrainz 197 + </ExternalLink> 198 + . 199 + </Text> 200 + </View> 201 + ) 158 202 )} 159 203 160 204 {/* Submit Button */} ··· 182 226 ); 183 227 } 184 228 229 + // Get 'best' release from MusicBrainz releases 230 + // 1. Sort releases by date (put non-released dates at the end) 231 + // 2. Return the oldest release where country is 'XW' or 'US' that is NOT the name of the track 232 + // 3. If none, return oldest release that is NOT the name of the track 233 + // 4. Return the oldest release. 234 + function getBestRelease(releases: MusicBrainzRelease[], trackTitle: string) { 235 + if (!releases || releases.length === 0) return null; 236 + if (releases.length === 1) return releases[0]; 237 + 238 + releases.sort( 239 + (a, b) => 240 + a.date?.localeCompare(b.date || "ZZZ") || 241 + a.title.localeCompare(b.title) || 242 + a.id.localeCompare(b.id), 243 + ); 244 + 245 + let bestRelease = releases.find( 246 + (release) => 247 + (release.country === "XW" || release.country === "US") && 248 + release.title !== trackTitle, 249 + ); 250 + if (!bestRelease) 251 + bestRelease = releases.find((release) => release.title !== trackTitle); 252 + 253 + if (!bestRelease) { 254 + console.log( 255 + "Could not find a suitable release for", 256 + trackTitle, 257 + "picking", 258 + releases[0]?.title, 259 + ); 260 + bestRelease = releases[0]; 261 + } 262 + 263 + return bestRelease; 264 + } 265 + 185 266 export function SearchResult({ 186 267 result, 187 268 onSelectTrack, ··· 191 272 }: SearchResultProps) { 192 273 const sheetRef = useRef<BottomSheetModal>(null); 193 274 194 - const currentRelease = selectedRelease || result.releases?.[0]; 275 + const currentRelease = 276 + selectedRelease || 277 + getBestRelease(result.releases || [], result.title) || 278 + result.releases?.[0]; 195 279 196 280 const showModal = () => { 197 281 sheetRef.current?.present(); ··· 213 297 }, 214 298 ); 215 299 }} 216 - className={`p-4 mb-2 rounded-lg ${ 300 + className={`px-4 py-2 mb-2 rounded-lg ${ 217 301 isSelected ? "bg-primary/20" : "bg-secondary/10" 218 302 }`} 219 303 > 220 - <View className="flex-row justify-between items-center gap-2"> 304 + <View className={`flex-row justify-between items-center gap-4`}> 221 305 <Image 222 306 className="w-16 h-16 rounded-lg bg-gray-500/50" 223 307 source={{ ··· 226 310 /> 227 311 <View className="flex-1"> 228 312 <Text className="font-bold text-sm line-clamp-2">{result.title}</Text> 229 - <Text className="text-sm text-gray-600"> 313 + <Text className="text-sm text-muted-foreground"> 230 314 {result["artist-credit"]?.[0]?.artist?.name ?? "Unknown Artist"} 231 315 </Text> 232 316 ··· 276 360 backdropComponent={SheetBackdrop} 277 361 handleComponent={SheetHandle} 278 362 > 279 - <View className="pb-4 border-b -mt-2 bg-background border-x border-neutral-500/30"> 363 + <View className="pb-4 border-b -mt-2 border-x border-neutral-500/30 bg-card"> 280 364 <Text className="text-lg font-bold text-center">Select Release</Text> 281 365 <TouchableOpacity 282 366 className="absolute right-4 top-1.5"
+105 -12
apps/amethyst/app/(tabs)/(stamp)/stamp/submit.tsx
··· 1 1 import VerticalPlayView from "@/components/play/verticalPlayView"; 2 2 import { Button } from "@/components/ui/button"; 3 3 import { useStore } from "@/stores/mainStore"; 4 - import { Agent, ComAtprotoRepoCreateRecord, RichText } from "@atproto/api"; 4 + import { 5 + Agent, 6 + BlobRef, 7 + ComAtprotoRepoCreateRecord, 8 + RichText, 9 + } from "@atproto/api"; 5 10 import { 6 11 Record as PlayRecord, 7 12 validateRecord, 8 13 } from "@teal/lexicons/src/types/fm/teal/alpha/feed/play"; 9 14 import { Redirect, Stack, useRouter } from "expo-router"; 10 - import { useContext, useState } from "react"; 15 + import { useContext, useEffect, useState } from "react"; 11 16 import { Switch, View } from "react-native"; 12 17 import { MusicBrainzRecording, PlaySubmittedData } from "@/lib/oldStamp"; 13 18 import { Text } from "@/components/ui/text"; 14 19 import { ExternalLink } from "@/components/ExternalLink"; 15 20 import { StampContext, StampContextValue, StampStep } from "./_layout"; 21 + import { Image } from "react-native"; 22 + import PlayView from "@/components/play/playView"; 16 23 17 24 type CardyBResponse = { 18 25 error: string; ··· 32 39 } 33 40 }; 34 41 42 + interface EmbedCard { 43 + $type: string; 44 + external: { 45 + uri: string; 46 + title: string; 47 + description: string; 48 + thumb: BlobRef; 49 + alt: string; 50 + cardyThumbUrl: string; 51 + }; 52 + } 53 + 35 54 const getBlueskyEmbedCard = async ( 36 55 url: string | undefined, 37 56 agent: Agent, 38 57 customUrl?: string, 39 58 customTitle?: string, 40 59 customDescription?: string, 41 - ) => { 60 + ): Promise<EmbedCard | undefined> => { 42 61 if (!url) return; 43 62 44 63 try { ··· 53 72 title: customTitle || metadata.title, 54 73 description: customDescription || metadata.description, 55 74 thumb: data.blob, 75 + alt: metadata.title, 76 + cardyThumbUrl: metadata.image, 56 77 }, 57 78 }; 58 79 } catch (error) { ··· 122 143 releaseName: result.selectedRelease?.title ?? undefined, 123 144 releaseMbId: result.selectedRelease?.id ?? undefined, 124 145 isrc: result.isrcs?.[0] ?? undefined, 125 - // not providing unless we have a way to map to tidal/odesli/etc 146 + // not providing unless we have a way to map to tidal/odesli/etc w/out MB 126 147 //originUrl: `https://tidal.com/browse/track/274816578?u`, 127 - musicServiceBaseDomain: "tidal.com", 148 + //musicServiceBaseDomain: "tidal.com", 149 + // TODO: update this based on version/git commit hash on build 128 150 submissionClientAgent: "tealtracker/0.0.1b", 129 151 playedTime: new Date().toISOString(), 130 152 }; ··· 139 161 const [isSubmitting, setIsSubmitting] = useState<boolean>(false); 140 162 const [shareWithBluesky, setShareWithBluesky] = useState<boolean>(false); 141 163 164 + const [blueskyEmbedCard, setBlueskyEmbedCard] = useState<EmbedCard | null>( 165 + null, 166 + ); // State to store Bluesky embed card 167 + 168 + const selectedTrack = 169 + state.step === StampStep.SUBMITTING ? state.submittingStamp : null; 170 + 171 + useEffect(() => { 172 + const fetchEmbedData = async (id: string) => { 173 + try { 174 + let info = await getEmbedInfo(id); 175 + if (info) { 176 + // After getting embedInfo, fetch Bluesky embed card 177 + if (info.urlEmbed && agent && selectedTrack) { 178 + // Ensure urlEmbed exists and agent is available 179 + let releaseYear = 180 + selectedTrack?.selectedRelease?.date?.split("-")[0]; 181 + let title = `${selectedTrack?.title} by ${selectedTrack?.["artist-credit"]?.map((artist) => artist.name).join(", ")}`; 182 + let description = `Song${releaseYear ? " · " + releaseYear : ""}${ 183 + selectedTrack?.length && " · " + ms2hms(selectedTrack.length) 184 + }`; 185 + const card = await getBlueskyEmbedCard( 186 + info.urlEmbed, 187 + agent, 188 + info.customUrl, 189 + title, 190 + description, 191 + ); 192 + console.log(card?.external.thumb); 193 + if (card) setBlueskyEmbedCard(card); // Store the fetched Bluesky embed card 194 + } 195 + } 196 + } catch (error) { 197 + console.error("Error fetching embed info:", error); 198 + return null; 199 + } 200 + }; 201 + 202 + if (selectedTrack?.id && shareWithBluesky) { 203 + fetchEmbedData(selectedTrack.id); 204 + } 205 + }, [selectedTrack, agent, shareWithBluesky]); 206 + 142 207 if (state.step !== StampStep.SUBMITTING) { 143 208 console.log("Stamp step is not SUBMITTING"); 144 209 console.log(state); 145 210 return <Redirect href="/stamp" />; 146 211 } 147 - 148 - const selectedTrack = state.submittingStamp; 149 212 150 213 if (selectedTrack === null) { 151 214 return <Text>No track selected</Text>; ··· 204 267 text: rt.text, 205 268 facets: rt.facets, 206 269 embed: urlEmbed 207 - ? await getBlueskyEmbedCard( 270 + ? ((await getBlueskyEmbedCard( 208 271 urlEmbed, 209 272 agent, 210 273 customUrl, 211 274 title, 212 275 description, 213 - ) 276 + )) as any) 214 277 : undefined, 215 278 }); 216 279 submittedData.blueskyPostUrl = post.uri ··· 239 302 title: "Submit Stamp", 240 303 }} 241 304 /> 242 - <View className="flex justify-between align-middle gap-4 max-w-screen-md w-screen min-h-full px-4"> 243 - <Text className="font-bold text-lg">Submit Play</Text> 305 + <View className="flex justify-between align-middle gap-4 max-w-2xl w-screen min-h-full px-4"> 306 + <View /> 244 307 <View> 245 308 <VerticalPlayView 309 + size={blueskyEmbedCard && shareWithBluesky ? "sm" : "md"} 246 310 releaseMbid={selectedTrack?.selectedRelease?.id || ""} 247 311 trackTitle={ 248 312 selectedTrack?.title || ··· 265 329 </View> 266 330 267 331 <View className="flex-col gap-4 items-center"> 332 + {blueskyEmbedCard && shareWithBluesky ? ( 333 + <View className="gap-2 w-full"> 334 + <Text className="text-sm text-muted-foreground text-center"> 335 + Card Preview: 336 + </Text> 337 + <View className="flex-col items-start rounded-xl bg-card border border-border"> 338 + <Image 339 + source={{ 340 + uri: blueskyEmbedCard.external.cardyThumbUrl, 341 + }} 342 + className="rounded-t-xl aspect-video w-full" 343 + /> 344 + <View className="p-2 items-start"> 345 + <Text className="text-card-foreground text-start font-semibold"> 346 + {blueskyEmbedCard.external.title} 347 + </Text> 348 + <Text className="text-muted-foreground text-start"> 349 + {blueskyEmbedCard.external.description} 350 + </Text> 351 + </View> 352 + </View> 353 + </View> 354 + ) : ( 355 + shareWithBluesky && ( 356 + <Text className="text-sm text-muted-foreground text-center"> 357 + jsyk: there won't be an embed card on your post. 358 + </Text> 359 + ) 360 + )} 268 361 <View className="flex-row gap-2 items-center"> 269 362 <Switch 270 363 value={shareWithBluesky} 271 364 onValueChange={setShareWithBluesky} 272 365 /> 273 - <Text className="text-lg text-gray-500 text-center"> 366 + <Text className="text-lg text-muted-foreground text-center"> 274 367 Share with Bluesky? 275 368 </Text> 276 369 </View>
+19 -1
apps/amethyst/app/(tabs)/_layout.tsx
··· 1 1 import React from "react"; 2 - import { FilePen, Home, LogOut, type LucideIcon } from "lucide-react-native"; 2 + import { 3 + FilePen, 4 + Home, 5 + LogOut, 6 + Settings, 7 + type LucideIcon, 8 + } from "lucide-react-native"; 3 9 import { Link, Tabs } from "expo-router"; 4 10 import { Pressable } from "react-native"; 5 11 ··· 30 36 // to prevent a hydration error in 31 37 // React Navigation v6. 32 38 headerShown: false, // useClientOnlyValue(false, true), 39 + headerStyle: { 40 + height: 50, 41 + }, 33 42 tabBarShowLabel: true, 34 43 tabBarStyle: { 35 44 //height: 75, ··· 63 72 title: "Stamp", 64 73 tabBarIcon: ({ color }) => ( 65 74 <TabBarIcon name={FilePen} color={color} /> 75 + ), 76 + }} 77 + /> 78 + <Tabs.Screen 79 + name="settings/index" 80 + options={{ 81 + title: "Settings", 82 + tabBarIcon: ({ color }) => ( 83 + <TabBarIcon name={Settings} color={color} /> 66 84 ), 67 85 }} 68 86 />
+56 -29
apps/amethyst/app/(tabs)/index.tsx
··· 1 1 import * as React from "react"; 2 - import { ActivityIndicator, ScrollView, View } from "react-native"; 2 + import { ActivityIndicator, ScrollView, View, Image } from "react-native"; 3 3 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 4 - import { CardHeader, CardTitle } from "../../components/ui/card"; 4 + import { CardTitle } from "../../components/ui/card"; 5 5 import { Text } from "@/components/ui/text"; 6 6 import { useStore } from "@/stores/mainStore"; 7 7 import AuthOptions from "../auth/options"; 8 8 9 9 import { Stack } from "expo-router"; 10 10 import ActorPlaysView from "@/components/play/actorPlaysView"; 11 + import { Button } from "@/components/ui/button"; 12 + import { Icon } from "@/lib/icons/iconWithClassName"; 13 + import { Plus } from "lucide-react-native"; 11 14 12 15 const GITHUB_AVATAR_URI = 13 16 "https://i.pinimg.com/originals/ef/a2/8d/efa28d18a04e7fa40ed49eeb0ab660db.jpg"; ··· 32 35 } 33 36 34 37 return ( 35 - <ScrollView className="flex-1 justify-start items-start gap-5 p-6 bg-background"> 38 + <ScrollView className="flex-1 justify-start items-center gap-5 bg-background w-full"> 36 39 <Stack.Screen 37 40 options={{ 38 41 title: "Home", 39 42 headerBackButtonDisplayMode: "minimal", 40 - headerShown: true, 43 + headerShown: false, 41 44 }} 42 45 /> 43 - <CardHeader className="items-start pb-0"> 44 - <Avatar alt="Rick Sanchez's Avatar" className="w-24 h-24"> 45 - <AvatarImage 46 - source={{ uri: profile.bsky?.avatar ?? GITHUB_AVATAR_URI }} 47 - /> 48 - <AvatarFallback> 49 - <Text> 50 - {profile.bsky?.displayName?.substring(0, 1) ?? " Richard"} 51 - </Text> 52 - </AvatarFallback> 53 - </Avatar> 54 - <View className="px-3" /> 55 - <CardTitle className="text-center"> 56 - {profile.bsky?.displayName ?? " Richard"} 57 - </CardTitle> 58 - {profile 59 - ? profile.bsky?.description?.split("\n").map((str, i) => ( 60 - <Text className="text-start self-start place-self-start" key={i}> 61 - {str} 62 - </Text> 63 - )) || "A very mysterious person" 64 - : "Loading..."} 65 - </CardHeader> 66 - <View className="max-w-xl w-full gap-2 pl-6 pt-6"> 67 - <Text className="text-left text-3xl font-serif">Your Stamps</Text> 46 + {profile.bsky?.banner && ( 47 + <Image 48 + className="w-full max-w-[100vh] h-32 md:h-44 scale-[1.32] rounded-xl -mb-6" 49 + source={{ uri: profile.bsky?.banner ?? GITHUB_AVATAR_URI }} 50 + /> 51 + )} 52 + <View className="flex flex-col items-left justify-start text-left max-w-2xl w-screen gap-1 p-4 px-8"> 53 + <View className="flex flex-row justify-between items-center"> 54 + <View className="flex justify-between"> 55 + <Avatar alt="Rick Sanchez's Avatar" className="w-24 h-24"> 56 + <AvatarImage 57 + source={{ uri: profile.bsky?.avatar ?? GITHUB_AVATAR_URI }} 58 + /> 59 + <AvatarFallback> 60 + <Text>{profile.bsky?.displayName?.substring(0, 1) ?? "R"}</Text> 61 + </AvatarFallback> 62 + </Avatar> 63 + <CardTitle className="text-left flex w-full justify-between mt-2"> 64 + {profile.bsky?.displayName ?? " Richard"} 65 + </CardTitle> 66 + </View> 67 + <View className="mt-8"> 68 + <Button 69 + variant="outline" 70 + size="sm" 71 + className="text-white rounded-xl flex flex-row gap-2 justify-center items-center" 72 + > 73 + <Icon icon={Plus} size={18} /> 74 + <Text>Follow</Text> 75 + </Button> 76 + </View> 77 + </View> 78 + <View> 79 + {profile 80 + ? profile.bsky?.description?.split("\n").map((str, i) => ( 81 + <Text 82 + className="text-start self-start place-self-start" 83 + key={i} 84 + > 85 + {str} 86 + </Text> 87 + )) || "A very mysterious person" 88 + : "Loading..."} 89 + </View> 90 + </View> 91 + <View className="max-w-2xl w-full gap-4 py-4 pl-8"> 92 + <Text className="text-left text-2xl border-b border-b-muted-foreground/30 -ml-2 pl-2 mr-6"> 93 + Your Stamps 94 + </Text> 68 95 <ActorPlaysView repo={agent?.did} /> 69 96 </View> 70 97 </ScrollView>
+100
apps/amethyst/app/(tabs)/settings/index.tsx
··· 1 + import React from "react"; 2 + import { Text } from "@/components/ui/text"; 3 + import { ScrollView, Switch, View } from "react-native"; 4 + import { Link, Stack } from "expo-router"; 5 + import { useColorScheme } from "@/lib/useColorScheme"; 6 + import { cn } from "@/lib/utils"; 7 + import { Button } from "@/components/ui/button"; 8 + 9 + export default function Settings() { 10 + const { colorScheme, setColorScheme } = useColorScheme(); 11 + const colorSchemeOptions = [ 12 + { label: "Light", value: "light" }, 13 + { label: "Dark", value: "dark" }, 14 + { label: "System", value: "system" }, 15 + ]; 16 + 17 + return ( 18 + <ScrollView className="flex-1 justify-start items-center gap-5 bg-background w-full"> 19 + <Stack.Screen 20 + options={{ 21 + title: "Settings", 22 + headerBackButtonDisplayMode: "minimal", 23 + headerShown: true, 24 + }} 25 + /> 26 + <View className="max-w-2xl flex-1 w-screen flex flex-col p-4 divide-y divide-muted-foreground/50 gap-4 rounded-xl my-2 mx-5"> 27 + <ButtonSelector 28 + text="Theme" 29 + values={colorSchemeOptions} 30 + selectedValue={colorScheme} 31 + setSelectedValue={setColorScheme} 32 + /> 33 + <Link href="/auth/logoutModal" asChild> 34 + <Button variant="destructive" size="sm" className="w-max mt-4 pb-1"> 35 + <Text>Sign out</Text> 36 + </Button> 37 + </Link> 38 + </View> 39 + </ScrollView> 40 + ); 41 + } 42 + 43 + function ToggleSwitch({ 44 + text, 45 + isEnabled, 46 + setIsEnabled, 47 + }: { 48 + text: string; 49 + isEnabled: boolean; 50 + setIsEnabled: React.Dispatch<React.SetStateAction<boolean>>; 51 + }) { 52 + const toggleSwitch = () => 53 + setIsEnabled((previousState: boolean) => !previousState); 54 + 55 + return ( 56 + <View className="flex-row items-center justify-between"> 57 + <Text className="text-lg">{text}</Text> 58 + <Switch className="ml-4" value={isEnabled} onValueChange={toggleSwitch} /> 59 + </View> 60 + ); 61 + } 62 + 63 + /// A selector component for smaller selections (usu. <3 values) 64 + function ButtonSelector({ 65 + text, 66 + values, 67 + selectedValue, 68 + setSelectedValue, 69 + }: { 70 + text: string; 71 + values: { label: string; value: string }[]; 72 + selectedValue: string; 73 + setSelectedValue: (value: any) => void; 74 + }) { 75 + return ( 76 + <View className="items-start gap-2 pt-2"> 77 + <Text className="text-lg">{text}</Text> 78 + <View className="flex-row items-center justify-around gap-1 w-full bg-muted h-10 px-1 rounded-xl"> 79 + {values.map(({ label, value }) => ( 80 + <Button 81 + key={value} 82 + onPress={() => setSelectedValue(value)} 83 + className={`flex-1 w-full h-8`} 84 + variant={selectedValue === value ? "secondary" : "ghost"} 85 + > 86 + <Text 87 + className={cn( 88 + selectedValue === value 89 + ? "text-foreground" 90 + : "text-muted-foreground", 91 + )} 92 + > 93 + {label} 94 + </Text> 95 + </Button> 96 + ))} 97 + </View> 98 + </View> 99 + ); 100 + }
+1 -1
apps/amethyst/app/_layout.tsx
··· 84 84 85 85 return ( 86 86 <SafeAreaView className="flex-1 flex flex-row min-h-screen justify-center bg-background"> 87 - <View className="max-w-screen-md flex flex-1 border-x border-muted-foreground/20"> 87 + <View className="max-w-2xl flex flex-1 border-x border-muted-foreground/20"> 88 88 {<RootLayoutNav />} 89 89 </View> 90 90 </SafeAreaView>
+9 -4
apps/amethyst/app/auth/logoutModal.tsx
··· 18 18 }; 19 19 return ( 20 20 <TouchableOpacity 21 - className="flex relative justify-center items-center bg-muted/60 w-full h-screen backdrop-blur-sm" 21 + className="flex justify-center items-center bg-muted/60 w-full h-screen backdrop-blur-sm fade-in animate-in" 22 22 onPress={() => handleGoBack()} 23 23 > 24 - <Icon icon={X} className="top-2 right-2 absolute" name="x" /> 25 - <View className="flex-1 items-center justify-center gap-2 bg-background w-full max-w-96 max-h-80 shadow-xl rounded-xl"> 24 + <View className="flex-1 relative items-center justify-center gap-2 bg-background w-full max-w-96 max-h-80 shadow-xl rounded-xl"> 25 + <Icon 26 + icon={X} 27 + className="top-2 right-2 absolute text-muted-foreground hover:text-foreground" 28 + name="x" 29 + /> 26 30 <Text className="text-4xl">Surprise!</Text> 27 31 <Text className="text-xl">You can sign out here!</Text> 32 + <Text className="text-xl -mt-2">but... are you sure?</Text> 28 33 <Button 29 34 onPress={() => { 30 35 logOut(); ··· 32 37 router.navigate("/"); 33 38 }} 34 39 > 35 - <Text className="text-lg">Sign out</Text> 40 + <Text className="text-lg">Sign Out</Text> 36 41 </Button> 37 42 38 43 {/* Use a light status bar on iOS to account for the black space above the modal */}
+9 -2
apps/amethyst/components/play/actorPlaysView.tsx
··· 1 1 import { useStore } from "@/stores/mainStore"; 2 2 import { Record as Play } from "@teal/lexicons/src/types/fm/teal/alpha/feed/play"; 3 3 import { useEffect, useState } from "react"; 4 - import { Text, ScrollView } from "react-native"; 4 + import { ScrollView } from "react-native"; 5 + import { Text } from "@/components/ui/text"; 5 6 import PlayView from "./playView"; 6 7 interface ActorPlaysViewProps { 7 8 repo: string | undefined; ··· 39 40 return ( 40 41 <ScrollView className="w-full *:gap-4"> 41 42 {play.map((p) => ( 42 - <PlayView key={p.uri} play={p.value} /> 43 + <PlayView 44 + key={p.uri} 45 + releaseTitle={p.value.releaseName} 46 + trackTitle={p.value.trackName} 47 + artistName={p.value.artistNames.join(", ")} 48 + releaseMbid={p.value.releaseMbId} 49 + /> 43 50 ))} 44 51 </ScrollView> 45 52 );
+27 -17
apps/amethyst/components/play/playView.tsx
··· 1 - import { Record as Play } from "@teal/lexicons/src/types/fm/teal/alpha/feed/play"; 2 - import { View, Image, Text } from "react-native"; 1 + import { View, Image } from "react-native"; 2 + import { Text } from "@/components/ui/text"; 3 3 4 - const PlayView = ({ play }: { play: Play }) => { 4 + export default function PlayView({ 5 + releaseMbid, 6 + trackTitle, 7 + artistName, 8 + releaseTitle, 9 + }: { 10 + releaseMbid?: string; 11 + trackTitle: string; 12 + artistName?: string; 13 + releaseTitle?: string; 14 + }) { 5 15 return ( 6 16 <View className="flex flex-row gap-2 max-w-full"> 7 17 <Image 8 - className="w-20 h-20 rounded-lg bg-gray-500/50" 18 + className="w-16 h-16 rounded-lg bg-gray-500/50" 9 19 source={{ 10 - uri: `https://coverartarchive.org/release/${play.releaseMbId}/front-250`, 20 + uri: 21 + releaseMbid && 22 + `https://coverartarchive.org/release/${releaseMbid}/front-250`, 11 23 }} 12 24 /> 13 - <View className="shrink"> 14 - <Text className="text-lg text-foreground line-clamp-1 overflow-ellipsis"> 15 - {play.trackName} 25 + <View className="shrink flex flex-col justify-center"> 26 + <Text className=" text-foreground line-clamp-1 overflow-ellipsis -mt-0.5"> 27 + {trackTitle} 16 28 </Text> 17 - {play.artistNames && ( 18 - <Text className="text-lg text-left text-muted-foreground"> 19 - {play.artistNames.join(", ")} 29 + {artistName && ( 30 + <Text className=" text-left text-muted-foreground line-clamp-1 overflow-ellipsis"> 31 + {artistName} 20 32 </Text> 21 33 )} 22 - {play.releaseName && ( 23 - <Text className="text-left text-muted-foreground line-clamp-1 overflow-ellipsis"> 24 - {play.releaseName} 34 + {releaseTitle && ( 35 + <Text className="text-sm text-left text-muted-foreground line-clamp-1 overflow-ellipsis"> 36 + {releaseTitle} 25 37 </Text> 26 38 )} 27 39 </View> 28 40 </View> 29 41 ); 30 - }; 31 - 32 - export default PlayView; 42 + }
+61 -11
apps/amethyst/components/play/verticalPlayView.tsx
··· 1 1 import { View, Image } from "react-native"; 2 - 3 2 import { Text } from "@/components/ui/text"; 3 + import { cn } from "@/lib/utils"; // Assuming you have a utils file with cn function like in shadcn 4 + 5 + type VerticalPlayViewProps = { 6 + releaseMbid: string; 7 + trackTitle: string; 8 + artistName?: string; 9 + releaseTitle?: string; 10 + size?: "default" | "sm" | "md" | "lg"; // Add size variant 11 + }; 4 12 5 13 export default function VerticalPlayView({ 6 14 releaseMbid, 7 15 trackTitle, 8 16 artistName, 9 17 releaseTitle, 10 - }: { 11 - releaseMbid: string; 12 - trackTitle: string; 13 - artistName?: string; 14 - releaseTitle?: string; 15 - }) { 18 + size = "default", // Default size is 'default' 19 + }: VerticalPlayViewProps) { 20 + // Define sizes for different variants 21 + const imageSizes = { 22 + default: "w-48 h-48", 23 + sm: "w-32 h-32", 24 + md: "w-48 h-48", 25 + lg: "w-64 h-64", 26 + }; 27 + 28 + const textSizes = { 29 + default: "text-xl", 30 + sm: "text-base", 31 + md: "text-xl", 32 + lg: "text-2xl", 33 + }; 34 + 35 + const secondaryTextSizes = { 36 + default: "text-lg", 37 + sm: "text-sm", 38 + md: "text-xl", 39 + lg: "text-xl", 40 + }; 41 + 42 + const marginBottoms = { 43 + default: "mb-2", 44 + sm: "mb-1", 45 + md: "mb-2", 46 + lg: "mb-4", 47 + }; 48 + 16 49 return ( 17 50 <View className="flex flex-col items-center"> 18 51 <Image 19 - className="w-48 h-48 rounded-lg bg-gray-500/50 mb-2" 52 + className={cn( 53 + imageSizes[size], // Apply image size based on variant 54 + "rounded-lg bg-gray-500/50", 55 + marginBottoms[size], // Apply margin bottom based on variant 56 + )} 20 57 source={{ 21 58 uri: `https://coverartarchive.org/release/${releaseMbid}/front-250`, 22 59 }} 23 60 /> 24 - <Text className="text-xl text-center">{trackTitle}</Text> 61 + <Text className={cn(textSizes[size], "text-center")}>{trackTitle}</Text>{" "} 62 + {/* Apply main text size based on variant */} 25 63 {artistName && ( 26 - <Text className="text-lg text-gray-500 text-center">{artistName}</Text> 64 + <Text 65 + className={cn( 66 + secondaryTextSizes[size], 67 + "text-muted-foreground text-center", 68 + )} 69 + > 70 + {artistName} 71 + </Text> 27 72 )} 28 73 {releaseTitle && ( 29 - <Text className="text-lg text-gray-500 text-center"> 74 + <Text 75 + className={cn( 76 + secondaryTextSizes[size], 77 + "text-muted-foreground text-center", 78 + )} 79 + > 30 80 {releaseTitle} 31 81 </Text> 32 82 )}
+1 -1
apps/amethyst/components/ui/sheetBackdrop.tsx
··· 50 50 style, 51 51 }: BottomSheetBackdropProps) => { 52 52 return ( 53 - <View className="w-full items-center h-6 bg-background rounded-t-xl border-t border-x border-neutral-500/30"> 53 + <View className="w-full items-center h-6 bg-card rounded-t-xl border-t border-x border-neutral-500/30"> 54 54 <View className="w-16 bg-muted-foreground/50 hover:bg-muted-foreground/70 transition-colors h-1.5 m-1 rounded-xl" /> 55 55 </View> 56 56 );
+1 -1
apps/amethyst/lib/useColorScheme.tsx
··· 4 4 const { colorScheme, setColorScheme, toggleColorScheme } = 5 5 useNativewindColorScheme(); 6 6 return { 7 - colorScheme: colorScheme ?? "dark", 7 + colorScheme: colorScheme || "dark", 8 8 isDarkColorScheme: colorScheme === "dark", 9 9 setColorScheme, 10 10 toggleColorScheme,