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
}
22
}, [segment]);
23
24
-
return <Stack screenOptions={{ headerShown: false }}>{rootScreen}</Stack>;
0
0
0
0
0
0
0
0
0
0
0
25
};
26
27
export default Layout;
···
21
}
22
}, [segment]);
23
24
+
return (
25
+
<Stack
26
+
screenOptions={{
27
+
headerShown: false,
28
+
headerStyle: {
29
+
height: 50,
30
+
} as any,
31
+
}}
32
+
>
33
+
{rootScreen}
34
+
</Stack>
35
+
);
36
};
37
38
export default Layout;
+7
-1
apps/amethyst/app/(tabs)/(stamp)/stamp/_layout.tsx
···
27
});
28
return (
29
<StampContext.Provider value={{ state, setState }}>
30
-
<Stack>
0
0
0
0
0
0
31
<Slot />
32
</Stack>
33
</StampContext.Provider>
···
27
});
28
return (
29
<StampContext.Provider value={{ state, setState }}>
30
+
<Stack
31
+
screenOptions={{
32
+
headerStyle: {
33
+
height: 50,
34
+
} as any,
35
+
}}
36
+
>
37
<Slot />
38
</Stack>
39
</StampContext.Provider>
+94
-10
apps/amethyst/app/(tabs)/(stamp)/stamp/index.tsx
···
1
import { Button } from "@/components/ui/button";
2
import { Icon } from "@/lib/icons/iconWithClassName";
3
-
import { Stack, useRouter } from "expo-router";
4
import { Check, ChevronDown, ChevronRight } from "lucide-react-native";
5
6
import React, { useContext, useEffect, useRef, useState } from "react";
···
16
import { Text } from "@/components/ui/text";
17
import {
18
MusicBrainzRecording,
0
19
ReleaseSelections,
20
searchMusicbrainz,
21
SearchParams,
···
24
import { BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
25
import SheetBackdrop, { SheetHandle } from "@/components/ui/sheetBackdrop";
26
import { StampContext, StampContextValue, StampStep } from "./_layout";
0
27
28
export default function StepOne() {
29
const router = useRouter();
···
45
{},
46
);
47
0
0
48
// reset search state if requested
49
useEffect(() => {
50
if (state.step === StampStep.IDLE && state.resetSearchState) {
···
65
const results = await searchMusicbrainz(searchFields);
66
setSearchResults(results);
67
setIsLoading(false);
0
68
};
69
70
const clearSearch = () => {
···
74
};
75
76
return (
77
-
<ScrollView className="flex-1 p-4 bg-background items-center">
78
<Stack.Screen
79
options={{
80
title: "Stamp a play manually",
···
82
}}
83
/>
84
{/* Search Form */}
85
-
<View className="flex gap-4 max-w-screen-md w-screen px-4">
86
<Text className="font-bold text-lg">Search for a track</Text>
87
<TextInput
88
className="p-2 border rounded-lg border-gray-300 bg-white"
···
110
}
111
}}
112
/>
0
0
0
0
0
0
0
0
0
0
0
0
0
113
<View className="flex-row gap-2">
114
<Button
115
className="flex-1"
···
130
</View>
131
132
{/* Search Results */}
133
-
<View className="flex gap-4 max-w-screen-md w-screen px-4">
134
-
{searchResults.length > 0 && (
135
<View className="mt-4">
136
<Text className="text-lg font-bold mb-2">
137
Search Results ({searchResults.length})
138
</Text>
0
139
<FlatList
140
data={searchResults}
141
renderItem={({ item }) => (
···
155
keyExtractor={(item) => item.id}
156
/>
157
</View>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
158
)}
159
160
{/* Submit Button */}
···
182
);
183
}
184
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
185
export function SearchResult({
186
result,
187
onSelectTrack,
···
191
}: SearchResultProps) {
192
const sheetRef = useRef<BottomSheetModal>(null);
193
194
-
const currentRelease = selectedRelease || result.releases?.[0];
0
0
0
195
196
const showModal = () => {
197
sheetRef.current?.present();
···
213
},
214
);
215
}}
216
-
className={`p-4 mb-2 rounded-lg ${
217
isSelected ? "bg-primary/20" : "bg-secondary/10"
218
}`}
219
>
220
-
<View className="flex-row justify-between items-center gap-2">
221
<Image
222
className="w-16 h-16 rounded-lg bg-gray-500/50"
223
source={{
···
226
/>
227
<View className="flex-1">
228
<Text className="font-bold text-sm line-clamp-2">{result.title}</Text>
229
-
<Text className="text-sm text-gray-600">
230
{result["artist-credit"]?.[0]?.artist?.name ?? "Unknown Artist"}
231
</Text>
232
···
276
backdropComponent={SheetBackdrop}
277
handleComponent={SheetHandle}
278
>
279
-
<View className="pb-4 border-b -mt-2 bg-background border-x border-neutral-500/30">
280
<Text className="text-lg font-bold text-center">Select Release</Text>
281
<TouchableOpacity
282
className="absolute right-4 top-1.5"
···
1
import { Button } from "@/components/ui/button";
2
import { Icon } from "@/lib/icons/iconWithClassName";
3
+
import { Link, Stack, useRouter } from "expo-router";
4
import { Check, ChevronDown, ChevronRight } from "lucide-react-native";
5
6
import React, { useContext, useEffect, useRef, useState } from "react";
···
16
import { Text } from "@/components/ui/text";
17
import {
18
MusicBrainzRecording,
19
+
MusicBrainzRelease,
20
ReleaseSelections,
21
searchMusicbrainz,
22
SearchParams,
···
25
import { BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
26
import SheetBackdrop, { SheetHandle } from "@/components/ui/sheetBackdrop";
27
import { StampContext, StampContextValue, StampStep } from "./_layout";
28
+
import { ExternalLink } from "@/components/ExternalLink";
29
30
export default function StepOne() {
31
const router = useRouter();
···
47
{},
48
);
49
50
+
const [hasSearched, setHasSearched] = useState<boolean>(false);
51
+
52
// reset search state if requested
53
useEffect(() => {
54
if (state.step === StampStep.IDLE && state.resetSearchState) {
···
69
const results = await searchMusicbrainz(searchFields);
70
setSearchResults(results);
71
setIsLoading(false);
72
+
setHasSearched(true);
73
};
74
75
const clearSearch = () => {
···
79
};
80
81
return (
82
+
<ScrollView className="flex-1 justify-start items-center w-min bg-background pt-2">
83
<Stack.Screen
84
options={{
85
title: "Stamp a play manually",
···
87
}}
88
/>
89
{/* Search Form */}
90
+
<View className="flex gap-4 max-w-2xl w-screen px-4">
91
<Text className="font-bold text-lg">Search for a track</Text>
92
<TextInput
93
className="p-2 border rounded-lg border-gray-300 bg-white"
···
115
}
116
}}
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
+
/>
131
<View className="flex-row gap-2">
132
<Button
133
className="flex-1"
···
148
</View>
149
150
{/* Search Results */}
151
+
<View className="flex gap-4 max-w-2xl w-screen px-4">
152
+
{searchResults.length > 0 ? (
153
<View className="mt-4">
154
<Text className="text-lg font-bold mb-2">
155
Search Results ({searchResults.length})
156
</Text>
157
+
158
<FlatList
159
data={searchResults}
160
renderItem={({ item }) => (
···
174
keyExtractor={(item) => item.id}
175
/>
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
+
)
202
)}
203
204
{/* Submit Button */}
···
226
);
227
}
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
+
266
export function SearchResult({
267
result,
268
onSelectTrack,
···
272
}: SearchResultProps) {
273
const sheetRef = useRef<BottomSheetModal>(null);
274
275
+
const currentRelease =
276
+
selectedRelease ||
277
+
getBestRelease(result.releases || [], result.title) ||
278
+
result.releases?.[0];
279
280
const showModal = () => {
281
sheetRef.current?.present();
···
297
},
298
);
299
}}
300
+
className={`px-4 py-2 mb-2 rounded-lg ${
301
isSelected ? "bg-primary/20" : "bg-secondary/10"
302
}`}
303
>
304
+
<View className={`flex-row justify-between items-center gap-4`}>
305
<Image
306
className="w-16 h-16 rounded-lg bg-gray-500/50"
307
source={{
···
310
/>
311
<View className="flex-1">
312
<Text className="font-bold text-sm line-clamp-2">{result.title}</Text>
313
+
<Text className="text-sm text-muted-foreground">
314
{result["artist-credit"]?.[0]?.artist?.name ?? "Unknown Artist"}
315
</Text>
316
···
360
backdropComponent={SheetBackdrop}
361
handleComponent={SheetHandle}
362
>
363
+
<View className="pb-4 border-b -mt-2 border-x border-neutral-500/30 bg-card">
364
<Text className="text-lg font-bold text-center">Select Release</Text>
365
<TouchableOpacity
366
className="absolute right-4 top-1.5"
+105
-12
apps/amethyst/app/(tabs)/(stamp)/stamp/submit.tsx
···
1
import VerticalPlayView from "@/components/play/verticalPlayView";
2
import { Button } from "@/components/ui/button";
3
import { useStore } from "@/stores/mainStore";
4
-
import { Agent, ComAtprotoRepoCreateRecord, RichText } from "@atproto/api";
0
0
0
0
0
5
import {
6
Record as PlayRecord,
7
validateRecord,
8
} from "@teal/lexicons/src/types/fm/teal/alpha/feed/play";
9
import { Redirect, Stack, useRouter } from "expo-router";
10
-
import { useContext, useState } from "react";
11
import { Switch, View } from "react-native";
12
import { MusicBrainzRecording, PlaySubmittedData } from "@/lib/oldStamp";
13
import { Text } from "@/components/ui/text";
14
import { ExternalLink } from "@/components/ExternalLink";
15
import { StampContext, StampContextValue, StampStep } from "./_layout";
0
0
16
17
type CardyBResponse = {
18
error: string;
···
32
}
33
};
34
0
0
0
0
0
0
0
0
0
0
0
0
35
const getBlueskyEmbedCard = async (
36
url: string | undefined,
37
agent: Agent,
38
customUrl?: string,
39
customTitle?: string,
40
customDescription?: string,
41
-
) => {
42
if (!url) return;
43
44
try {
···
53
title: customTitle || metadata.title,
54
description: customDescription || metadata.description,
55
thumb: data.blob,
0
0
56
},
57
};
58
} catch (error) {
···
122
releaseName: result.selectedRelease?.title ?? undefined,
123
releaseMbId: result.selectedRelease?.id ?? undefined,
124
isrc: result.isrcs?.[0] ?? undefined,
125
-
// not providing unless we have a way to map to tidal/odesli/etc
126
//originUrl: `https://tidal.com/browse/track/274816578?u`,
127
-
musicServiceBaseDomain: "tidal.com",
0
128
submissionClientAgent: "tealtracker/0.0.1b",
129
playedTime: new Date().toISOString(),
130
};
···
139
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
140
const [shareWithBluesky, setShareWithBluesky] = useState<boolean>(false);
141
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
142
if (state.step !== StampStep.SUBMITTING) {
143
console.log("Stamp step is not SUBMITTING");
144
console.log(state);
145
return <Redirect href="/stamp" />;
146
}
147
-
148
-
const selectedTrack = state.submittingStamp;
149
150
if (selectedTrack === null) {
151
return <Text>No track selected</Text>;
···
204
text: rt.text,
205
facets: rt.facets,
206
embed: urlEmbed
207
-
? await getBlueskyEmbedCard(
208
urlEmbed,
209
agent,
210
customUrl,
211
title,
212
description,
213
-
)
214
: undefined,
215
});
216
submittedData.blueskyPostUrl = post.uri
···
239
title: "Submit Stamp",
240
}}
241
/>
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>
244
<View>
245
<VerticalPlayView
0
246
releaseMbid={selectedTrack?.selectedRelease?.id || ""}
247
trackTitle={
248
selectedTrack?.title ||
···
265
</View>
266
267
<View className="flex-col gap-4 items-center">
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
268
<View className="flex-row gap-2 items-center">
269
<Switch
270
value={shareWithBluesky}
271
onValueChange={setShareWithBluesky}
272
/>
273
-
<Text className="text-lg text-gray-500 text-center">
274
Share with Bluesky?
275
</Text>
276
</View>
···
1
import VerticalPlayView from "@/components/play/verticalPlayView";
2
import { Button } from "@/components/ui/button";
3
import { useStore } from "@/stores/mainStore";
4
+
import {
5
+
Agent,
6
+
BlobRef,
7
+
ComAtprotoRepoCreateRecord,
8
+
RichText,
9
+
} from "@atproto/api";
10
import {
11
Record as PlayRecord,
12
validateRecord,
13
} from "@teal/lexicons/src/types/fm/teal/alpha/feed/play";
14
import { Redirect, Stack, useRouter } from "expo-router";
15
+
import { useContext, useEffect, useState } from "react";
16
import { Switch, View } from "react-native";
17
import { MusicBrainzRecording, PlaySubmittedData } from "@/lib/oldStamp";
18
import { Text } from "@/components/ui/text";
19
import { ExternalLink } from "@/components/ExternalLink";
20
import { StampContext, StampContextValue, StampStep } from "./_layout";
21
+
import { Image } from "react-native";
22
+
import PlayView from "@/components/play/playView";
23
24
type CardyBResponse = {
25
error: string;
···
39
}
40
};
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
+
54
const getBlueskyEmbedCard = async (
55
url: string | undefined,
56
agent: Agent,
57
customUrl?: string,
58
customTitle?: string,
59
customDescription?: string,
60
+
): Promise<EmbedCard | undefined> => {
61
if (!url) return;
62
63
try {
···
72
title: customTitle || metadata.title,
73
description: customDescription || metadata.description,
74
thumb: data.blob,
75
+
alt: metadata.title,
76
+
cardyThumbUrl: metadata.image,
77
},
78
};
79
} catch (error) {
···
143
releaseName: result.selectedRelease?.title ?? undefined,
144
releaseMbId: result.selectedRelease?.id ?? undefined,
145
isrc: result.isrcs?.[0] ?? undefined,
146
+
// not providing unless we have a way to map to tidal/odesli/etc w/out MB
147
//originUrl: `https://tidal.com/browse/track/274816578?u`,
148
+
//musicServiceBaseDomain: "tidal.com",
149
+
// TODO: update this based on version/git commit hash on build
150
submissionClientAgent: "tealtracker/0.0.1b",
151
playedTime: new Date().toISOString(),
152
};
···
161
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
162
const [shareWithBluesky, setShareWithBluesky] = useState<boolean>(false);
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
+
207
if (state.step !== StampStep.SUBMITTING) {
208
console.log("Stamp step is not SUBMITTING");
209
console.log(state);
210
return <Redirect href="/stamp" />;
211
}
0
0
212
213
if (selectedTrack === null) {
214
return <Text>No track selected</Text>;
···
267
text: rt.text,
268
facets: rt.facets,
269
embed: urlEmbed
270
+
? ((await getBlueskyEmbedCard(
271
urlEmbed,
272
agent,
273
customUrl,
274
title,
275
description,
276
+
)) as any)
277
: undefined,
278
});
279
submittedData.blueskyPostUrl = post.uri
···
302
title: "Submit Stamp",
303
}}
304
/>
305
+
<View className="flex justify-between align-middle gap-4 max-w-2xl w-screen min-h-full px-4">
306
+
<View />
307
<View>
308
<VerticalPlayView
309
+
size={blueskyEmbedCard && shareWithBluesky ? "sm" : "md"}
310
releaseMbid={selectedTrack?.selectedRelease?.id || ""}
311
trackTitle={
312
selectedTrack?.title ||
···
329
</View>
330
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
+
)}
361
<View className="flex-row gap-2 items-center">
362
<Switch
363
value={shareWithBluesky}
364
onValueChange={setShareWithBluesky}
365
/>
366
+
<Text className="text-lg text-muted-foreground text-center">
367
Share with Bluesky?
368
</Text>
369
</View>
+19
-1
apps/amethyst/app/(tabs)/_layout.tsx
···
1
import React from "react";
2
-
import { FilePen, Home, LogOut, type LucideIcon } from "lucide-react-native";
0
0
0
0
0
0
3
import { Link, Tabs } from "expo-router";
4
import { Pressable } from "react-native";
5
···
30
// to prevent a hydration error in
31
// React Navigation v6.
32
headerShown: false, // useClientOnlyValue(false, true),
0
0
0
33
tabBarShowLabel: true,
34
tabBarStyle: {
35
//height: 75,
···
63
title: "Stamp",
64
tabBarIcon: ({ color }) => (
65
<TabBarIcon name={FilePen} color={color} />
0
0
0
0
0
0
0
0
0
66
),
67
}}
68
/>
···
1
import React from "react";
2
+
import {
3
+
FilePen,
4
+
Home,
5
+
LogOut,
6
+
Settings,
7
+
type LucideIcon,
8
+
} from "lucide-react-native";
9
import { Link, Tabs } from "expo-router";
10
import { Pressable } from "react-native";
11
···
36
// to prevent a hydration error in
37
// React Navigation v6.
38
headerShown: false, // useClientOnlyValue(false, true),
39
+
headerStyle: {
40
+
height: 50,
41
+
},
42
tabBarShowLabel: true,
43
tabBarStyle: {
44
//height: 75,
···
72
title: "Stamp",
73
tabBarIcon: ({ color }) => (
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} />
84
),
85
}}
86
/>
+56
-29
apps/amethyst/app/(tabs)/index.tsx
···
1
import * as React from "react";
2
-
import { ActivityIndicator, ScrollView, View } from "react-native";
3
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
4
-
import { CardHeader, CardTitle } from "../../components/ui/card";
5
import { Text } from "@/components/ui/text";
6
import { useStore } from "@/stores/mainStore";
7
import AuthOptions from "../auth/options";
8
9
import { Stack } from "expo-router";
10
import ActorPlaysView from "@/components/play/actorPlaysView";
0
0
0
11
12
const GITHUB_AVATAR_URI =
13
"https://i.pinimg.com/originals/ef/a2/8d/efa28d18a04e7fa40ed49eeb0ab660db.jpg";
···
32
}
33
34
return (
35
-
<ScrollView className="flex-1 justify-start items-start gap-5 p-6 bg-background">
36
<Stack.Screen
37
options={{
38
title: "Home",
39
headerBackButtonDisplayMode: "minimal",
40
-
headerShown: true,
41
}}
42
/>
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>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
68
<ActorPlaysView repo={agent?.did} />
69
</View>
70
</ScrollView>
···
1
import * as React from "react";
2
+
import { ActivityIndicator, ScrollView, View, Image } from "react-native";
3
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
4
+
import { CardTitle } from "../../components/ui/card";
5
import { Text } from "@/components/ui/text";
6
import { useStore } from "@/stores/mainStore";
7
import AuthOptions from "../auth/options";
8
9
import { Stack } from "expo-router";
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";
14
15
const GITHUB_AVATAR_URI =
16
"https://i.pinimg.com/originals/ef/a2/8d/efa28d18a04e7fa40ed49eeb0ab660db.jpg";
···
35
}
36
37
return (
38
+
<ScrollView className="flex-1 justify-start items-center gap-5 bg-background w-full">
39
<Stack.Screen
40
options={{
41
title: "Home",
42
headerBackButtonDisplayMode: "minimal",
43
+
headerShown: false,
44
}}
45
/>
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>
95
<ActorPlaysView repo={agent?.did} />
96
</View>
97
</ScrollView>
+100
apps/amethyst/app/(tabs)/settings/index.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
85
return (
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">
88
{<RootLayoutNav />}
89
</View>
90
</SafeAreaView>
···
84
85
return (
86
<SafeAreaView className="flex-1 flex flex-row min-h-screen justify-center bg-background">
87
+
<View className="max-w-2xl flex flex-1 border-x border-muted-foreground/20">
88
{<RootLayoutNav />}
89
</View>
90
</SafeAreaView>
+9
-4
apps/amethyst/app/auth/logoutModal.tsx
···
18
};
19
return (
20
<TouchableOpacity
21
-
className="flex relative justify-center items-center bg-muted/60 w-full h-screen backdrop-blur-sm"
22
onPress={() => handleGoBack()}
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">
0
0
0
0
26
<Text className="text-4xl">Surprise!</Text>
27
<Text className="text-xl">You can sign out here!</Text>
0
28
<Button
29
onPress={() => {
30
logOut();
···
32
router.navigate("/");
33
}}
34
>
35
-
<Text className="text-lg">Sign out</Text>
36
</Button>
37
38
{/* Use a light status bar on iOS to account for the black space above the modal */}
···
18
};
19
return (
20
<TouchableOpacity
21
+
className="flex justify-center items-center bg-muted/60 w-full h-screen backdrop-blur-sm fade-in animate-in"
22
onPress={() => handleGoBack()}
23
>
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
+
/>
30
<Text className="text-4xl">Surprise!</Text>
31
<Text className="text-xl">You can sign out here!</Text>
32
+
<Text className="text-xl -mt-2">but... are you sure?</Text>
33
<Button
34
onPress={() => {
35
logOut();
···
37
router.navigate("/");
38
}}
39
>
40
+
<Text className="text-lg">Sign Out</Text>
41
</Button>
42
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
import { useStore } from "@/stores/mainStore";
2
import { Record as Play } from "@teal/lexicons/src/types/fm/teal/alpha/feed/play";
3
import { useEffect, useState } from "react";
4
-
import { Text, ScrollView } from "react-native";
0
5
import PlayView from "./playView";
6
interface ActorPlaysViewProps {
7
repo: string | undefined;
···
39
return (
40
<ScrollView className="w-full *:gap-4">
41
{play.map((p) => (
42
-
<PlayView key={p.uri} play={p.value} />
0
0
0
0
0
0
43
))}
44
</ScrollView>
45
);
···
1
import { useStore } from "@/stores/mainStore";
2
import { Record as Play } from "@teal/lexicons/src/types/fm/teal/alpha/feed/play";
3
import { useEffect, useState } from "react";
4
+
import { ScrollView } from "react-native";
5
+
import { Text } from "@/components/ui/text";
6
import PlayView from "./playView";
7
interface ActorPlaysViewProps {
8
repo: string | undefined;
···
40
return (
41
<ScrollView className="w-full *:gap-4">
42
{play.map((p) => (
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
+
/>
50
))}
51
</ScrollView>
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";
3
4
-
const PlayView = ({ play }: { play: Play }) => {
0
0
0
0
0
0
0
0
0
0
5
return (
6
<View className="flex flex-row gap-2 max-w-full">
7
<Image
8
-
className="w-20 h-20 rounded-lg bg-gray-500/50"
9
source={{
10
-
uri: `https://coverartarchive.org/release/${play.releaseMbId}/front-250`,
0
0
11
}}
12
/>
13
-
<View className="shrink">
14
-
<Text className="text-lg text-foreground line-clamp-1 overflow-ellipsis">
15
-
{play.trackName}
16
</Text>
17
-
{play.artistNames && (
18
-
<Text className="text-lg text-left text-muted-foreground">
19
-
{play.artistNames.join(", ")}
20
</Text>
21
)}
22
-
{play.releaseName && (
23
-
<Text className="text-left text-muted-foreground line-clamp-1 overflow-ellipsis">
24
-
{play.releaseName}
25
</Text>
26
)}
27
</View>
28
</View>
29
);
30
-
};
31
-
32
-
export default PlayView;
···
1
+
import { View, Image } from "react-native";
2
+
import { Text } from "@/components/ui/text";
3
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
+
}) {
15
return (
16
<View className="flex flex-row gap-2 max-w-full">
17
<Image
18
+
className="w-16 h-16 rounded-lg bg-gray-500/50"
19
source={{
20
+
uri:
21
+
releaseMbid &&
22
+
`https://coverartarchive.org/release/${releaseMbid}/front-250`,
23
}}
24
/>
25
+
<View className="shrink flex flex-col justify-center">
26
+
<Text className=" text-foreground line-clamp-1 overflow-ellipsis -mt-0.5">
27
+
{trackTitle}
28
</Text>
29
+
{artistName && (
30
+
<Text className=" text-left text-muted-foreground line-clamp-1 overflow-ellipsis">
31
+
{artistName}
32
</Text>
33
)}
34
+
{releaseTitle && (
35
+
<Text className="text-sm text-left text-muted-foreground line-clamp-1 overflow-ellipsis">
36
+
{releaseTitle}
37
</Text>
38
)}
39
</View>
40
</View>
41
);
42
+
}
0
0
+61
-11
apps/amethyst/components/play/verticalPlayView.tsx
···
1
import { View, Image } from "react-native";
2
-
3
import { Text } from "@/components/ui/text";
0
0
0
0
0
0
0
0
0
4
5
export default function VerticalPlayView({
6
releaseMbid,
7
trackTitle,
8
artistName,
9
releaseTitle,
10
-
}: {
11
-
releaseMbid: string;
12
-
trackTitle: string;
13
-
artistName?: string;
14
-
releaseTitle?: string;
15
-
}) {
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
16
return (
17
<View className="flex flex-col items-center">
18
<Image
19
-
className="w-48 h-48 rounded-lg bg-gray-500/50 mb-2"
0
0
0
0
20
source={{
21
uri: `https://coverartarchive.org/release/${releaseMbid}/front-250`,
22
}}
23
/>
24
-
<Text className="text-xl text-center">{trackTitle}</Text>
0
25
{artistName && (
26
-
<Text className="text-lg text-gray-500 text-center">{artistName}</Text>
0
0
0
0
0
0
0
27
)}
28
{releaseTitle && (
29
-
<Text className="text-lg text-gray-500 text-center">
0
0
0
0
0
30
{releaseTitle}
31
</Text>
32
)}
···
1
import { View, Image } from "react-native";
0
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
+
};
12
13
export default function VerticalPlayView({
14
releaseMbid,
15
trackTitle,
16
artistName,
17
releaseTitle,
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
+
49
return (
50
<View className="flex flex-col items-center">
51
<Image
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
+
)}
57
source={{
58
uri: `https://coverartarchive.org/release/${releaseMbid}/front-250`,
59
}}
60
/>
61
+
<Text className={cn(textSizes[size], "text-center")}>{trackTitle}</Text>{" "}
62
+
{/* Apply main text size based on variant */}
63
{artistName && (
64
+
<Text
65
+
className={cn(
66
+
secondaryTextSizes[size],
67
+
"text-muted-foreground text-center",
68
+
)}
69
+
>
70
+
{artistName}
71
+
</Text>
72
)}
73
{releaseTitle && (
74
+
<Text
75
+
className={cn(
76
+
secondaryTextSizes[size],
77
+
"text-muted-foreground text-center",
78
+
)}
79
+
>
80
{releaseTitle}
81
</Text>
82
)}
+1
-1
apps/amethyst/components/ui/sheetBackdrop.tsx
···
50
style,
51
}: BottomSheetBackdropProps) => {
52
return (
53
-
<View className="w-full items-center h-6 bg-background rounded-t-xl border-t border-x border-neutral-500/30">
54
<View className="w-16 bg-muted-foreground/50 hover:bg-muted-foreground/70 transition-colors h-1.5 m-1 rounded-xl" />
55
</View>
56
);
···
50
style,
51
}: BottomSheetBackdropProps) => {
52
return (
53
+
<View className="w-full items-center h-6 bg-card rounded-t-xl border-t border-x border-neutral-500/30">
54
<View className="w-16 bg-muted-foreground/50 hover:bg-muted-foreground/70 transition-colors h-1.5 m-1 rounded-xl" />
55
</View>
56
);
+1
-1
apps/amethyst/lib/useColorScheme.tsx
···
4
const { colorScheme, setColorScheme, toggleColorScheme } =
5
useNativewindColorScheme();
6
return {
7
-
colorScheme: colorScheme ?? "dark",
8
isDarkColorScheme: colorScheme === "dark",
9
setColorScheme,
10
toggleColorScheme,
···
4
const { colorScheme, setColorScheme, toggleColorScheme } =
5
useNativewindColorScheme();
6
return {
7
+
colorScheme: colorScheme || "dark",
8
isDarkColorScheme: colorScheme === "dark",
9
setColorScheme,
10
toggleColorScheme,