tangled
alpha
login
or
join now
teal.fm
/
teal
110
fork
atom
Your music, beautifully tracked. All yours. (coming soon)
teal.fm
teal-fm
atproto
110
fork
atom
overview
issues
pulls
pipelines
clean up styles, add settings
Natalie B.
1 year ago
c3396031
de493866
+502
-91
14 changed files
expand all
collapse all
unified
split
apps
amethyst
app
(tabs)
(stamp)
_layout.tsx
stamp
_layout.tsx
index.tsx
submit.tsx
_layout.tsx
index.tsx
settings
index.tsx
_layout.tsx
auth
logoutModal.tsx
components
play
actorPlaysView.tsx
playView.tsx
verticalPlayView.tsx
ui
sheetBackdrop.tsx
lib
useColorScheme.tsx
+12
-1
apps/amethyst/app/(tabs)/(stamp)/_layout.tsx
···
21
21
}
22
22
}, [segment]);
23
23
24
24
-
return <Stack screenOptions={{ headerShown: false }}>{rootScreen}</Stack>;
24
24
+
return (
25
25
+
<Stack
26
26
+
screenOptions={{
27
27
+
headerShown: false,
28
28
+
headerStyle: {
29
29
+
height: 50,
30
30
+
} as any,
31
31
+
}}
32
32
+
>
33
33
+
{rootScreen}
34
34
+
</Stack>
35
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
30
-
<Stack>
30
30
+
<Stack
31
31
+
screenOptions={{
32
32
+
headerStyle: {
33
33
+
height: 50,
34
34
+
} as any,
35
35
+
}}
36
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
3
-
import { Stack, useRouter } from "expo-router";
3
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
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
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
50
+
const [hasSearched, setHasSearched] = useState<boolean>(false);
51
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
72
+
setHasSearched(true);
68
73
};
69
74
70
75
const clearSearch = () => {
···
74
79
};
75
80
76
81
return (
77
77
-
<ScrollView className="flex-1 p-4 bg-background items-center">
82
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
85
-
<View className="flex gap-4 max-w-screen-md w-screen px-4">
90
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
118
+
<TextInput
119
119
+
className="p-2 border rounded-lg border-gray-300 bg-white"
120
120
+
placeholder="Album name..."
121
121
+
value={searchFields.release}
122
122
+
onChangeText={(text) =>
123
123
+
setSearchFields((prev) => ({ ...prev, release: text }))
124
124
+
}
125
125
+
onKeyPress={(e) => {
126
126
+
if (e.nativeEvent.key === "Enter") {
127
127
+
handleSearch();
128
128
+
}
129
129
+
}}
130
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
133
-
<View className="flex gap-4 max-w-screen-md w-screen px-4">
134
134
-
{searchResults.length > 0 && (
151
151
+
<View className="flex gap-4 max-w-2xl w-screen px-4">
152
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
157
+
139
158
<FlatList
140
159
data={searchResults}
141
160
renderItem={({ item }) => (
···
155
174
keyExtractor={(item) => item.id}
156
175
/>
157
176
</View>
177
177
+
) : (
178
178
+
hasSearched && (
179
179
+
<View className="mt-4">
180
180
+
<Text className="text-lg text-muted-foreground mb-2 text-center">
181
181
+
No search results found.
182
182
+
</Text>
183
183
+
<Text className="text-lg text-muted-foreground mb-2 text-center">
184
184
+
Please try importing with{" "}
185
185
+
<ExternalLink
186
186
+
href="https://harmony.pulsewidth.org.uk/"
187
187
+
className="border-b border-muted-foreground/60 text-bsky"
188
188
+
>
189
189
+
Harmony
190
190
+
</ExternalLink>{" "}
191
191
+
or manually on{" "}
192
192
+
<ExternalLink
193
193
+
href="https://musicbrainz.org/release/add"
194
194
+
className="border-b border-muted-foreground/60 text-bsky"
195
195
+
>
196
196
+
Musicbrainz
197
197
+
</ExternalLink>
198
198
+
.
199
199
+
</Text>
200
200
+
</View>
201
201
+
)
158
202
)}
159
203
160
204
{/* Submit Button */}
···
182
226
);
183
227
}
184
228
229
229
+
// Get 'best' release from MusicBrainz releases
230
230
+
// 1. Sort releases by date (put non-released dates at the end)
231
231
+
// 2. Return the oldest release where country is 'XW' or 'US' that is NOT the name of the track
232
232
+
// 3. If none, return oldest release that is NOT the name of the track
233
233
+
// 4. Return the oldest release.
234
234
+
function getBestRelease(releases: MusicBrainzRelease[], trackTitle: string) {
235
235
+
if (!releases || releases.length === 0) return null;
236
236
+
if (releases.length === 1) return releases[0];
237
237
+
238
238
+
releases.sort(
239
239
+
(a, b) =>
240
240
+
a.date?.localeCompare(b.date || "ZZZ") ||
241
241
+
a.title.localeCompare(b.title) ||
242
242
+
a.id.localeCompare(b.id),
243
243
+
);
244
244
+
245
245
+
let bestRelease = releases.find(
246
246
+
(release) =>
247
247
+
(release.country === "XW" || release.country === "US") &&
248
248
+
release.title !== trackTitle,
249
249
+
);
250
250
+
if (!bestRelease)
251
251
+
bestRelease = releases.find((release) => release.title !== trackTitle);
252
252
+
253
253
+
if (!bestRelease) {
254
254
+
console.log(
255
255
+
"Could not find a suitable release for",
256
256
+
trackTitle,
257
257
+
"picking",
258
258
+
releases[0]?.title,
259
259
+
);
260
260
+
bestRelease = releases[0];
261
261
+
}
262
262
+
263
263
+
return bestRelease;
264
264
+
}
265
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
194
-
const currentRelease = selectedRelease || result.releases?.[0];
275
275
+
const currentRelease =
276
276
+
selectedRelease ||
277
277
+
getBestRelease(result.releases || [], result.title) ||
278
278
+
result.releases?.[0];
195
279
196
280
const showModal = () => {
197
281
sheetRef.current?.present();
···
213
297
},
214
298
);
215
299
}}
216
216
-
className={`p-4 mb-2 rounded-lg ${
300
300
+
className={`px-4 py-2 mb-2 rounded-lg ${
217
301
isSelected ? "bg-primary/20" : "bg-secondary/10"
218
302
}`}
219
303
>
220
220
-
<View className="flex-row justify-between items-center gap-2">
304
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
229
-
<Text className="text-sm text-gray-600">
313
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
279
-
<View className="pb-4 border-b -mt-2 bg-background border-x border-neutral-500/30">
363
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
4
-
import { Agent, ComAtprotoRepoCreateRecord, RichText } from "@atproto/api";
4
4
+
import {
5
5
+
Agent,
6
6
+
BlobRef,
7
7
+
ComAtprotoRepoCreateRecord,
8
8
+
RichText,
9
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
10
-
import { useContext, useState } from "react";
15
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
21
+
import { Image } from "react-native";
22
22
+
import PlayView from "@/components/play/playView";
16
23
17
24
type CardyBResponse = {
18
25
error: string;
···
32
39
}
33
40
};
34
41
42
42
+
interface EmbedCard {
43
43
+
$type: string;
44
44
+
external: {
45
45
+
uri: string;
46
46
+
title: string;
47
47
+
description: string;
48
48
+
thumb: BlobRef;
49
49
+
alt: string;
50
50
+
cardyThumbUrl: string;
51
51
+
};
52
52
+
}
53
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
41
-
) => {
60
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
75
+
alt: metadata.title,
76
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
125
-
// not providing unless we have a way to map to tidal/odesli/etc
146
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
127
-
musicServiceBaseDomain: "tidal.com",
148
148
+
//musicServiceBaseDomain: "tidal.com",
149
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
164
+
const [blueskyEmbedCard, setBlueskyEmbedCard] = useState<EmbedCard | null>(
165
165
+
null,
166
166
+
); // State to store Bluesky embed card
167
167
+
168
168
+
const selectedTrack =
169
169
+
state.step === StampStep.SUBMITTING ? state.submittingStamp : null;
170
170
+
171
171
+
useEffect(() => {
172
172
+
const fetchEmbedData = async (id: string) => {
173
173
+
try {
174
174
+
let info = await getEmbedInfo(id);
175
175
+
if (info) {
176
176
+
// After getting embedInfo, fetch Bluesky embed card
177
177
+
if (info.urlEmbed && agent && selectedTrack) {
178
178
+
// Ensure urlEmbed exists and agent is available
179
179
+
let releaseYear =
180
180
+
selectedTrack?.selectedRelease?.date?.split("-")[0];
181
181
+
let title = `${selectedTrack?.title} by ${selectedTrack?.["artist-credit"]?.map((artist) => artist.name).join(", ")}`;
182
182
+
let description = `Song${releaseYear ? " · " + releaseYear : ""}${
183
183
+
selectedTrack?.length && " · " + ms2hms(selectedTrack.length)
184
184
+
}`;
185
185
+
const card = await getBlueskyEmbedCard(
186
186
+
info.urlEmbed,
187
187
+
agent,
188
188
+
info.customUrl,
189
189
+
title,
190
190
+
description,
191
191
+
);
192
192
+
console.log(card?.external.thumb);
193
193
+
if (card) setBlueskyEmbedCard(card); // Store the fetched Bluesky embed card
194
194
+
}
195
195
+
}
196
196
+
} catch (error) {
197
197
+
console.error("Error fetching embed info:", error);
198
198
+
return null;
199
199
+
}
200
200
+
};
201
201
+
202
202
+
if (selectedTrack?.id && shareWithBluesky) {
203
203
+
fetchEmbedData(selectedTrack.id);
204
204
+
}
205
205
+
}, [selectedTrack, agent, shareWithBluesky]);
206
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
147
-
148
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
207
-
? await getBlueskyEmbedCard(
270
270
+
? ((await getBlueskyEmbedCard(
208
271
urlEmbed,
209
272
agent,
210
273
customUrl,
211
274
title,
212
275
description,
213
213
-
)
276
276
+
)) as any)
214
277
: undefined,
215
278
});
216
279
submittedData.blueskyPostUrl = post.uri
···
239
302
title: "Submit Stamp",
240
303
}}
241
304
/>
242
242
-
<View className="flex justify-between align-middle gap-4 max-w-screen-md w-screen min-h-full px-4">
243
243
-
<Text className="font-bold text-lg">Submit Play</Text>
305
305
+
<View className="flex justify-between align-middle gap-4 max-w-2xl w-screen min-h-full px-4">
306
306
+
<View />
244
307
<View>
245
308
<VerticalPlayView
309
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
332
+
{blueskyEmbedCard && shareWithBluesky ? (
333
333
+
<View className="gap-2 w-full">
334
334
+
<Text className="text-sm text-muted-foreground text-center">
335
335
+
Card Preview:
336
336
+
</Text>
337
337
+
<View className="flex-col items-start rounded-xl bg-card border border-border">
338
338
+
<Image
339
339
+
source={{
340
340
+
uri: blueskyEmbedCard.external.cardyThumbUrl,
341
341
+
}}
342
342
+
className="rounded-t-xl aspect-video w-full"
343
343
+
/>
344
344
+
<View className="p-2 items-start">
345
345
+
<Text className="text-card-foreground text-start font-semibold">
346
346
+
{blueskyEmbedCard.external.title}
347
347
+
</Text>
348
348
+
<Text className="text-muted-foreground text-start">
349
349
+
{blueskyEmbedCard.external.description}
350
350
+
</Text>
351
351
+
</View>
352
352
+
</View>
353
353
+
</View>
354
354
+
) : (
355
355
+
shareWithBluesky && (
356
356
+
<Text className="text-sm text-muted-foreground text-center">
357
357
+
jsyk: there won't be an embed card on your post.
358
358
+
</Text>
359
359
+
)
360
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
273
-
<Text className="text-lg text-gray-500 text-center">
366
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
2
-
import { FilePen, Home, LogOut, type LucideIcon } from "lucide-react-native";
2
2
+
import {
3
3
+
FilePen,
4
4
+
Home,
5
5
+
LogOut,
6
6
+
Settings,
7
7
+
type LucideIcon,
8
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
39
+
headerStyle: {
40
40
+
height: 50,
41
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
75
+
),
76
76
+
}}
77
77
+
/>
78
78
+
<Tabs.Screen
79
79
+
name="settings/index"
80
80
+
options={{
81
81
+
title: "Settings",
82
82
+
tabBarIcon: ({ color }) => (
83
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
2
-
import { ActivityIndicator, ScrollView, View } from "react-native";
2
2
+
import { ActivityIndicator, ScrollView, View, Image } from "react-native";
3
3
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
4
4
-
import { CardHeader, CardTitle } from "../../components/ui/card";
4
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
11
+
import { Button } from "@/components/ui/button";
12
12
+
import { Icon } from "@/lib/icons/iconWithClassName";
13
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
35
-
<ScrollView className="flex-1 justify-start items-start gap-5 p-6 bg-background">
38
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
40
-
headerShown: true,
43
43
+
headerShown: false,
41
44
}}
42
45
/>
43
43
-
<CardHeader className="items-start pb-0">
44
44
-
<Avatar alt="Rick Sanchez's Avatar" className="w-24 h-24">
45
45
-
<AvatarImage
46
46
-
source={{ uri: profile.bsky?.avatar ?? GITHUB_AVATAR_URI }}
47
47
-
/>
48
48
-
<AvatarFallback>
49
49
-
<Text>
50
50
-
{profile.bsky?.displayName?.substring(0, 1) ?? " Richard"}
51
51
-
</Text>
52
52
-
</AvatarFallback>
53
53
-
</Avatar>
54
54
-
<View className="px-3" />
55
55
-
<CardTitle className="text-center">
56
56
-
{profile.bsky?.displayName ?? " Richard"}
57
57
-
</CardTitle>
58
58
-
{profile
59
59
-
? profile.bsky?.description?.split("\n").map((str, i) => (
60
60
-
<Text className="text-start self-start place-self-start" key={i}>
61
61
-
{str}
62
62
-
</Text>
63
63
-
)) || "A very mysterious person"
64
64
-
: "Loading..."}
65
65
-
</CardHeader>
66
66
-
<View className="max-w-xl w-full gap-2 pl-6 pt-6">
67
67
-
<Text className="text-left text-3xl font-serif">Your Stamps</Text>
46
46
+
{profile.bsky?.banner && (
47
47
+
<Image
48
48
+
className="w-full max-w-[100vh] h-32 md:h-44 scale-[1.32] rounded-xl -mb-6"
49
49
+
source={{ uri: profile.bsky?.banner ?? GITHUB_AVATAR_URI }}
50
50
+
/>
51
51
+
)}
52
52
+
<View className="flex flex-col items-left justify-start text-left max-w-2xl w-screen gap-1 p-4 px-8">
53
53
+
<View className="flex flex-row justify-between items-center">
54
54
+
<View className="flex justify-between">
55
55
+
<Avatar alt="Rick Sanchez's Avatar" className="w-24 h-24">
56
56
+
<AvatarImage
57
57
+
source={{ uri: profile.bsky?.avatar ?? GITHUB_AVATAR_URI }}
58
58
+
/>
59
59
+
<AvatarFallback>
60
60
+
<Text>{profile.bsky?.displayName?.substring(0, 1) ?? "R"}</Text>
61
61
+
</AvatarFallback>
62
62
+
</Avatar>
63
63
+
<CardTitle className="text-left flex w-full justify-between mt-2">
64
64
+
{profile.bsky?.displayName ?? " Richard"}
65
65
+
</CardTitle>
66
66
+
</View>
67
67
+
<View className="mt-8">
68
68
+
<Button
69
69
+
variant="outline"
70
70
+
size="sm"
71
71
+
className="text-white rounded-xl flex flex-row gap-2 justify-center items-center"
72
72
+
>
73
73
+
<Icon icon={Plus} size={18} />
74
74
+
<Text>Follow</Text>
75
75
+
</Button>
76
76
+
</View>
77
77
+
</View>
78
78
+
<View>
79
79
+
{profile
80
80
+
? profile.bsky?.description?.split("\n").map((str, i) => (
81
81
+
<Text
82
82
+
className="text-start self-start place-self-start"
83
83
+
key={i}
84
84
+
>
85
85
+
{str}
86
86
+
</Text>
87
87
+
)) || "A very mysterious person"
88
88
+
: "Loading..."}
89
89
+
</View>
90
90
+
</View>
91
91
+
<View className="max-w-2xl w-full gap-4 py-4 pl-8">
92
92
+
<Text className="text-left text-2xl border-b border-b-muted-foreground/30 -ml-2 pl-2 mr-6">
93
93
+
Your Stamps
94
94
+
</Text>
68
95
<ActorPlaysView repo={agent?.did} />
69
96
</View>
70
97
</ScrollView>
+100
apps/amethyst/app/(tabs)/settings/index.tsx
···
1
1
+
import React from "react";
2
2
+
import { Text } from "@/components/ui/text";
3
3
+
import { ScrollView, Switch, View } from "react-native";
4
4
+
import { Link, Stack } from "expo-router";
5
5
+
import { useColorScheme } from "@/lib/useColorScheme";
6
6
+
import { cn } from "@/lib/utils";
7
7
+
import { Button } from "@/components/ui/button";
8
8
+
9
9
+
export default function Settings() {
10
10
+
const { colorScheme, setColorScheme } = useColorScheme();
11
11
+
const colorSchemeOptions = [
12
12
+
{ label: "Light", value: "light" },
13
13
+
{ label: "Dark", value: "dark" },
14
14
+
{ label: "System", value: "system" },
15
15
+
];
16
16
+
17
17
+
return (
18
18
+
<ScrollView className="flex-1 justify-start items-center gap-5 bg-background w-full">
19
19
+
<Stack.Screen
20
20
+
options={{
21
21
+
title: "Settings",
22
22
+
headerBackButtonDisplayMode: "minimal",
23
23
+
headerShown: true,
24
24
+
}}
25
25
+
/>
26
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
27
+
<ButtonSelector
28
28
+
text="Theme"
29
29
+
values={colorSchemeOptions}
30
30
+
selectedValue={colorScheme}
31
31
+
setSelectedValue={setColorScheme}
32
32
+
/>
33
33
+
<Link href="/auth/logoutModal" asChild>
34
34
+
<Button variant="destructive" size="sm" className="w-max mt-4 pb-1">
35
35
+
<Text>Sign out</Text>
36
36
+
</Button>
37
37
+
</Link>
38
38
+
</View>
39
39
+
</ScrollView>
40
40
+
);
41
41
+
}
42
42
+
43
43
+
function ToggleSwitch({
44
44
+
text,
45
45
+
isEnabled,
46
46
+
setIsEnabled,
47
47
+
}: {
48
48
+
text: string;
49
49
+
isEnabled: boolean;
50
50
+
setIsEnabled: React.Dispatch<React.SetStateAction<boolean>>;
51
51
+
}) {
52
52
+
const toggleSwitch = () =>
53
53
+
setIsEnabled((previousState: boolean) => !previousState);
54
54
+
55
55
+
return (
56
56
+
<View className="flex-row items-center justify-between">
57
57
+
<Text className="text-lg">{text}</Text>
58
58
+
<Switch className="ml-4" value={isEnabled} onValueChange={toggleSwitch} />
59
59
+
</View>
60
60
+
);
61
61
+
}
62
62
+
63
63
+
/// A selector component for smaller selections (usu. <3 values)
64
64
+
function ButtonSelector({
65
65
+
text,
66
66
+
values,
67
67
+
selectedValue,
68
68
+
setSelectedValue,
69
69
+
}: {
70
70
+
text: string;
71
71
+
values: { label: string; value: string }[];
72
72
+
selectedValue: string;
73
73
+
setSelectedValue: (value: any) => void;
74
74
+
}) {
75
75
+
return (
76
76
+
<View className="items-start gap-2 pt-2">
77
77
+
<Text className="text-lg">{text}</Text>
78
78
+
<View className="flex-row items-center justify-around gap-1 w-full bg-muted h-10 px-1 rounded-xl">
79
79
+
{values.map(({ label, value }) => (
80
80
+
<Button
81
81
+
key={value}
82
82
+
onPress={() => setSelectedValue(value)}
83
83
+
className={`flex-1 w-full h-8`}
84
84
+
variant={selectedValue === value ? "secondary" : "ghost"}
85
85
+
>
86
86
+
<Text
87
87
+
className={cn(
88
88
+
selectedValue === value
89
89
+
? "text-foreground"
90
90
+
: "text-muted-foreground",
91
91
+
)}
92
92
+
>
93
93
+
{label}
94
94
+
</Text>
95
95
+
</Button>
96
96
+
))}
97
97
+
</View>
98
98
+
</View>
99
99
+
);
100
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
87
-
<View className="max-w-screen-md flex flex-1 border-x border-muted-foreground/20">
87
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
21
-
className="flex relative justify-center items-center bg-muted/60 w-full h-screen backdrop-blur-sm"
21
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
24
-
<Icon icon={X} className="top-2 right-2 absolute" name="x" />
25
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
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
25
+
<Icon
26
26
+
icon={X}
27
27
+
className="top-2 right-2 absolute text-muted-foreground hover:text-foreground"
28
28
+
name="x"
29
29
+
/>
26
30
<Text className="text-4xl">Surprise!</Text>
27
31
<Text className="text-xl">You can sign out here!</Text>
32
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
35
-
<Text className="text-lg">Sign out</Text>
40
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
4
-
import { Text, ScrollView } from "react-native";
4
4
+
import { ScrollView } from "react-native";
5
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
42
-
<PlayView key={p.uri} play={p.value} />
43
43
+
<PlayView
44
44
+
key={p.uri}
45
45
+
releaseTitle={p.value.releaseName}
46
46
+
trackTitle={p.value.trackName}
47
47
+
artistName={p.value.artistNames.join(", ")}
48
48
+
releaseMbid={p.value.releaseMbId}
49
49
+
/>
43
50
))}
44
51
</ScrollView>
45
52
);
+27
-17
apps/amethyst/components/play/playView.tsx
···
1
1
-
import { Record as Play } from "@teal/lexicons/src/types/fm/teal/alpha/feed/play";
2
2
-
import { View, Image, Text } from "react-native";
1
1
+
import { View, Image } from "react-native";
2
2
+
import { Text } from "@/components/ui/text";
3
3
4
4
-
const PlayView = ({ play }: { play: Play }) => {
4
4
+
export default function PlayView({
5
5
+
releaseMbid,
6
6
+
trackTitle,
7
7
+
artistName,
8
8
+
releaseTitle,
9
9
+
}: {
10
10
+
releaseMbid?: string;
11
11
+
trackTitle: string;
12
12
+
artistName?: string;
13
13
+
releaseTitle?: string;
14
14
+
}) {
5
15
return (
6
16
<View className="flex flex-row gap-2 max-w-full">
7
17
<Image
8
8
-
className="w-20 h-20 rounded-lg bg-gray-500/50"
18
18
+
className="w-16 h-16 rounded-lg bg-gray-500/50"
9
19
source={{
10
10
-
uri: `https://coverartarchive.org/release/${play.releaseMbId}/front-250`,
20
20
+
uri:
21
21
+
releaseMbid &&
22
22
+
`https://coverartarchive.org/release/${releaseMbid}/front-250`,
11
23
}}
12
24
/>
13
13
-
<View className="shrink">
14
14
-
<Text className="text-lg text-foreground line-clamp-1 overflow-ellipsis">
15
15
-
{play.trackName}
25
25
+
<View className="shrink flex flex-col justify-center">
26
26
+
<Text className=" text-foreground line-clamp-1 overflow-ellipsis -mt-0.5">
27
27
+
{trackTitle}
16
28
</Text>
17
17
-
{play.artistNames && (
18
18
-
<Text className="text-lg text-left text-muted-foreground">
19
19
-
{play.artistNames.join(", ")}
29
29
+
{artistName && (
30
30
+
<Text className=" text-left text-muted-foreground line-clamp-1 overflow-ellipsis">
31
31
+
{artistName}
20
32
</Text>
21
33
)}
22
22
-
{play.releaseName && (
23
23
-
<Text className="text-left text-muted-foreground line-clamp-1 overflow-ellipsis">
24
24
-
{play.releaseName}
34
34
+
{releaseTitle && (
35
35
+
<Text className="text-sm text-left text-muted-foreground line-clamp-1 overflow-ellipsis">
36
36
+
{releaseTitle}
25
37
</Text>
26
38
)}
27
39
</View>
28
40
</View>
29
41
);
30
30
-
};
31
31
-
32
32
-
export default PlayView;
42
42
+
}
+61
-11
apps/amethyst/components/play/verticalPlayView.tsx
···
1
1
import { View, Image } from "react-native";
2
2
-
3
2
import { Text } from "@/components/ui/text";
3
3
+
import { cn } from "@/lib/utils"; // Assuming you have a utils file with cn function like in shadcn
4
4
+
5
5
+
type VerticalPlayViewProps = {
6
6
+
releaseMbid: string;
7
7
+
trackTitle: string;
8
8
+
artistName?: string;
9
9
+
releaseTitle?: string;
10
10
+
size?: "default" | "sm" | "md" | "lg"; // Add size variant
11
11
+
};
4
12
5
13
export default function VerticalPlayView({
6
14
releaseMbid,
7
15
trackTitle,
8
16
artistName,
9
17
releaseTitle,
10
10
-
}: {
11
11
-
releaseMbid: string;
12
12
-
trackTitle: string;
13
13
-
artistName?: string;
14
14
-
releaseTitle?: string;
15
15
-
}) {
18
18
+
size = "default", // Default size is 'default'
19
19
+
}: VerticalPlayViewProps) {
20
20
+
// Define sizes for different variants
21
21
+
const imageSizes = {
22
22
+
default: "w-48 h-48",
23
23
+
sm: "w-32 h-32",
24
24
+
md: "w-48 h-48",
25
25
+
lg: "w-64 h-64",
26
26
+
};
27
27
+
28
28
+
const textSizes = {
29
29
+
default: "text-xl",
30
30
+
sm: "text-base",
31
31
+
md: "text-xl",
32
32
+
lg: "text-2xl",
33
33
+
};
34
34
+
35
35
+
const secondaryTextSizes = {
36
36
+
default: "text-lg",
37
37
+
sm: "text-sm",
38
38
+
md: "text-xl",
39
39
+
lg: "text-xl",
40
40
+
};
41
41
+
42
42
+
const marginBottoms = {
43
43
+
default: "mb-2",
44
44
+
sm: "mb-1",
45
45
+
md: "mb-2",
46
46
+
lg: "mb-4",
47
47
+
};
48
48
+
16
49
return (
17
50
<View className="flex flex-col items-center">
18
51
<Image
19
19
-
className="w-48 h-48 rounded-lg bg-gray-500/50 mb-2"
52
52
+
className={cn(
53
53
+
imageSizes[size], // Apply image size based on variant
54
54
+
"rounded-lg bg-gray-500/50",
55
55
+
marginBottoms[size], // Apply margin bottom based on variant
56
56
+
)}
20
57
source={{
21
58
uri: `https://coverartarchive.org/release/${releaseMbid}/front-250`,
22
59
}}
23
60
/>
24
24
-
<Text className="text-xl text-center">{trackTitle}</Text>
61
61
+
<Text className={cn(textSizes[size], "text-center")}>{trackTitle}</Text>{" "}
62
62
+
{/* Apply main text size based on variant */}
25
63
{artistName && (
26
26
-
<Text className="text-lg text-gray-500 text-center">{artistName}</Text>
64
64
+
<Text
65
65
+
className={cn(
66
66
+
secondaryTextSizes[size],
67
67
+
"text-muted-foreground text-center",
68
68
+
)}
69
69
+
>
70
70
+
{artistName}
71
71
+
</Text>
27
72
)}
28
73
{releaseTitle && (
29
29
-
<Text className="text-lg text-gray-500 text-center">
74
74
+
<Text
75
75
+
className={cn(
76
76
+
secondaryTextSizes[size],
77
77
+
"text-muted-foreground text-center",
78
78
+
)}
79
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
53
-
<View className="w-full items-center h-6 bg-background rounded-t-xl border-t border-x border-neutral-500/30">
53
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
7
-
colorScheme: colorScheme ?? "dark",
7
7
+
colorScheme: colorScheme || "dark",
8
8
isDarkColorScheme: colorScheme === "dark",
9
9
setColorScheme,
10
10
toggleColorScheme,