feat: turbostarter boilerplate
Production-ready Next.js boilerplate with: - Runtime env validation (fail-fast on missing vars) - Feature-gated config (S3, Stripe, email, OAuth) - Docker + Coolify deployment pipeline - PostgreSQL + pgvector, MinIO S3, Better Auth - TypeScript strict mode (no ignoreBuildErrors) - i18n (en/es), AI modules, billing, monitoring Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
379
apps/mobile/src/modules/common/avatar-form.tsx
Normal file
379
apps/mobile/src/modules/common/avatar-form.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { useMutation, useMutationState } from "@tanstack/react-query";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { FormProvider, useForm, useFormContext } from "react-hook-form";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import * as z from "zod";
|
||||
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-mobile/avatar";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
|
||||
import { api } from "~/lib/api";
|
||||
import { useImagePicker } from "~/modules/common/hooks/use-image-picker";
|
||||
|
||||
import type { ImagePickerAsset } from "expo-image-picker";
|
||||
|
||||
interface AvatarFormProps {
|
||||
readonly id: string;
|
||||
readonly image?: string | null;
|
||||
readonly update: (image: string | null) => Promise<unknown>;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ACCEPTED_IMAGE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
];
|
||||
|
||||
const mutations = {
|
||||
upload: {
|
||||
mutationKey: ["avatar", "upload"] as const,
|
||||
mutationFn: async ({
|
||||
avatar,
|
||||
id,
|
||||
image,
|
||||
update,
|
||||
}: AvatarFormProps & { avatar?: ImagePickerAsset }) => {
|
||||
if (!avatar) throw new Error("No file selected");
|
||||
|
||||
const guessedExtensionFromMime = avatar.mimeType?.split("/").pop();
|
||||
const guessedExtensionFromUri = avatar.uri.split(".").pop();
|
||||
const extension =
|
||||
guessedExtensionFromMime ?? guessedExtensionFromUri ?? "jpg";
|
||||
const uuid = String(Date.now());
|
||||
const path = `avatars/${id}-${uuid}.${extension}`;
|
||||
|
||||
const blob = await fetch(avatar.uri).then((r) => r.blob());
|
||||
|
||||
const { url: uploadUrl } = await handle(api.storage.upload.$get)({
|
||||
query: { path },
|
||||
});
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
body: blob,
|
||||
headers: {
|
||||
"Content-Type": avatar.mimeType ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
const { url: publicUrl } = await handle(api.storage.public.$get)({
|
||||
query: { path },
|
||||
});
|
||||
|
||||
await update(publicUrl);
|
||||
|
||||
return { publicUrl, oldImage: image };
|
||||
},
|
||||
},
|
||||
remove: {
|
||||
mutationKey: ["avatar", "remove"] as const,
|
||||
mutationFn: async ({ image, update }: Omit<AvatarFormProps, "id">) => {
|
||||
const path = image?.split("/").pop();
|
||||
if (!path) return;
|
||||
|
||||
const { url: deleteUrl } = await handle(api.storage.delete.$get)({
|
||||
query: { path: `avatars/${path}` },
|
||||
});
|
||||
|
||||
await update(null);
|
||||
|
||||
void fetch(deleteUrl, { method: "DELETE" });
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const useAvatarFormSchema = () => {
|
||||
const assetSchema = z.object({
|
||||
uri: z.string().min(1),
|
||||
mimeType: z.string().optional(),
|
||||
fileSize: z.number().optional(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
});
|
||||
|
||||
return z.object({
|
||||
avatar: assetSchema
|
||||
.refine((file) => (file.fileSize ?? MAX_FILE_SIZE) <= MAX_FILE_SIZE, {
|
||||
message: "error.tooBig.file.inclusive",
|
||||
path: ["avatar"],
|
||||
})
|
||||
.refine(
|
||||
(file) =>
|
||||
!file.mimeType || ACCEPTED_IMAGE_TYPES.includes(file.mimeType),
|
||||
{
|
||||
message: "error.file.type",
|
||||
path: ["avatar"],
|
||||
},
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
interface AvatarFormContextValue extends AvatarFormProps {
|
||||
previewUrl: string | null;
|
||||
setPreviewUrl: (previewUrl: string | null) => void;
|
||||
}
|
||||
|
||||
const AvatarFormContext = createContext<AvatarFormContextValue | null>(null);
|
||||
|
||||
const useAvatarFormContext = () => {
|
||||
const context = useContext(AvatarFormContext);
|
||||
if (!context) {
|
||||
throw new Error("useAvatarFormContext must be used within a AvatarForm!");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const AvatarForm = ({
|
||||
id,
|
||||
image,
|
||||
update,
|
||||
children,
|
||||
}: AvatarFormProps & { children: React.ReactNode }) => {
|
||||
const [previewUrl, setPreviewUrl] = useState(image ?? null);
|
||||
const _avatarSchema = useAvatarFormSchema();
|
||||
const form = useForm<z.infer<typeof _avatarSchema>>();
|
||||
|
||||
return (
|
||||
<AvatarFormContext.Provider
|
||||
value={{ id, image, update, previewUrl, setPreviewUrl }}
|
||||
>
|
||||
<FormProvider {...form}>{children}</FormProvider>
|
||||
</AvatarFormContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarFormUploadButton = ({
|
||||
className,
|
||||
onUpload,
|
||||
disabled,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button> & { onUpload?: () => void }) => {
|
||||
const { t } = useTranslation(["common", "validation"]);
|
||||
const { pick } = useImagePicker();
|
||||
const { image, setPreviewUrl, id, update } = useAvatarFormContext();
|
||||
const avatarSchema = useAvatarFormSchema();
|
||||
|
||||
const { setError, clearErrors } =
|
||||
useFormContext<z.infer<typeof avatarSchema>>();
|
||||
|
||||
const upload = useMutation({
|
||||
...mutations.upload,
|
||||
onError: (error) => {
|
||||
setPreviewUrl(image ?? null);
|
||||
Alert.alert(
|
||||
t("common:error.title"),
|
||||
error.message || t("common:error.general"),
|
||||
);
|
||||
},
|
||||
onSuccess: async ({ publicUrl, oldImage }) => {
|
||||
clearErrors();
|
||||
setPreviewUrl(publicUrl);
|
||||
|
||||
if (oldImage) {
|
||||
const oldPath = oldImage.split("/").pop();
|
||||
if (oldPath) {
|
||||
const { url: deleteUrl } = await handle(api.storage.delete.$get)({
|
||||
query: { path: `avatars/${oldPath}` },
|
||||
});
|
||||
void fetch(deleteUrl, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
|
||||
onUpload?.();
|
||||
},
|
||||
});
|
||||
|
||||
const [removeStatus] = useMutationState({
|
||||
filters: { mutationKey: mutations.remove.mutationKey },
|
||||
select: (mutation) => mutation.state.status,
|
||||
});
|
||||
|
||||
const handlePick = async () => {
|
||||
const asset = await pick();
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = avatarSchema.safeParse({ avatar: asset });
|
||||
if (!result.success) {
|
||||
const firstIssue = result.error.issues[0];
|
||||
const firstMsg = firstIssue?.message ?? "";
|
||||
setError("avatar", { message: firstMsg });
|
||||
Alert.alert(t("common:error.title"), firstMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewUrl(asset.uri);
|
||||
upload.mutate({
|
||||
avatar: asset,
|
||||
id,
|
||||
image,
|
||||
update,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
hitSlop={4}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"dark:bg-background active:bg-muted absolute -right-2 -bottom-2.5 rounded-full",
|
||||
className,
|
||||
)}
|
||||
onPress={handlePick}
|
||||
disabled={disabled ?? (upload.isPending || removeStatus === "pending")}
|
||||
{...props}
|
||||
>
|
||||
<Icons.Pencil size={14} className="text-foreground" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarFormPreview = ({
|
||||
className,
|
||||
fallback,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Avatar> & { fallback?: React.ReactNode }) => {
|
||||
const { previewUrl } = useAvatarFormContext();
|
||||
const _avatarSchema = useAvatarFormSchema();
|
||||
const { formState } = useFormContext<z.infer<typeof _avatarSchema>>();
|
||||
|
||||
const mutationStatuses = useMutationState({
|
||||
filters: {
|
||||
predicate: (mutation) =>
|
||||
mutation.options.mutationKey === mutations.upload.mutationKey ||
|
||||
mutation.options.mutationKey === mutations.remove.mutationKey,
|
||||
},
|
||||
select: (mutation) => mutation.state.status,
|
||||
});
|
||||
|
||||
const hasError =
|
||||
Boolean(formState.errors.avatar) ||
|
||||
mutationStatuses.some((s) => s === "error");
|
||||
|
||||
return (
|
||||
<View className="relative">
|
||||
<Avatar
|
||||
className={cn(
|
||||
"size-26",
|
||||
hasError ? "ring-destructive ring-2 ring-offset-2" : "",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{previewUrl && <AvatarImage source={{ uri: previewUrl }} />}
|
||||
{mutationStatuses.some((status) => status === "pending") && (
|
||||
<>
|
||||
<View className="bg-background absolute inset-0 rounded-full opacity-50" />
|
||||
<View className="absolute inset-0 items-center justify-center rounded-full">
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-muted-foreground" size={28} />
|
||||
</Spin>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<AvatarFallback>
|
||||
{fallback ?? (
|
||||
<Icons.UserRound
|
||||
width={50}
|
||||
height={50}
|
||||
className="text-foreground"
|
||||
/>
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarFormRemoveButton = ({
|
||||
className,
|
||||
onRemove,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button> & { onRemove?: () => void }) => {
|
||||
const { image, update, previewUrl, setPreviewUrl } = useAvatarFormContext();
|
||||
const { clearErrors } = useFormContext();
|
||||
|
||||
const [uploadStatus] = useMutationState({
|
||||
filters: {
|
||||
mutationKey: mutations.upload.mutationKey,
|
||||
},
|
||||
select: (mutation) => mutation.state.status,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
...mutations.remove,
|
||||
onMutate: () => {
|
||||
setPreviewUrl(null);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setPreviewUrl(null);
|
||||
onRemove?.();
|
||||
},
|
||||
});
|
||||
|
||||
if (!previewUrl || uploadStatus === "pending") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"dark:bg-background active:bg-muted absolute -top-2 -right-2 rounded-full",
|
||||
className,
|
||||
)}
|
||||
disabled={remove.isPending}
|
||||
onPress={() => {
|
||||
clearErrors();
|
||||
remove.mutate({ image, update });
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<Icons.X size={16} strokeWidth={2} className="text-foreground" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarFormErrorMessage = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Text>) => {
|
||||
const _avatarSchema = useAvatarFormSchema();
|
||||
const { formState } = useFormContext<z.infer<typeof _avatarSchema>>();
|
||||
|
||||
if (!formState.errors.avatar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text className={cn("text-destructive text-xs", className)} {...props}>
|
||||
{formState.errors.avatar.message}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
AvatarForm,
|
||||
AvatarFormPreview,
|
||||
AvatarFormRemoveButton,
|
||||
AvatarFormUploadButton,
|
||||
AvatarFormErrorMessage,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
export const useCopyToClipboard = () => {
|
||||
const [copiedText, setCopiedText] = useState<string | null>(null);
|
||||
|
||||
const copy = useCallback(async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
await Clipboard.setStringAsync(text);
|
||||
setCopiedText(text);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("Failed to copy to clipboard:", error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return [copiedText, copy] as const;
|
||||
};
|
||||
36
apps/mobile/src/modules/common/hooks/use-image-picker.tsx
Normal file
36
apps/mobile/src/modules/common/hooks/use-image-picker.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as ExpoImagePicker from "expo-image-picker";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
export const useImagePicker = () => {
|
||||
const pick = useCallback(async () => {
|
||||
try {
|
||||
const result = await ExpoImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ["images"],
|
||||
allowsEditing: true,
|
||||
quality: 0.6,
|
||||
});
|
||||
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingResult = await ExpoImagePicker.getPendingResultAsync();
|
||||
|
||||
const image =
|
||||
result.assets[0] ??
|
||||
(pendingResult && "assets" in pendingResult
|
||||
? pendingResult.assets?.[0]
|
||||
: null);
|
||||
|
||||
return image;
|
||||
} catch (e) {
|
||||
logger.error("Error on image pick: ", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
pick,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { focusManager } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { AppState, Platform } from "react-native";
|
||||
|
||||
import type { AppStateStatus } from "react-native";
|
||||
|
||||
export function useRefetchOnAppFocus() {
|
||||
useEffect(() => {
|
||||
const onAppStateChange = (status: AppStateStatus) => {
|
||||
if (Platform.OS !== "web") {
|
||||
focusManager.setFocused(status === "active");
|
||||
}
|
||||
};
|
||||
|
||||
const subscription = AppState.addEventListener("change", onAppStateChange);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import React from "react";
|
||||
|
||||
export function useRefetchOnFocus<T>(refetch: () => Promise<T>) {
|
||||
const firstTimeRef = React.useRef(true);
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
if (firstTimeRef.current) {
|
||||
firstTimeRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
void refetch();
|
||||
}, [refetch]),
|
||||
);
|
||||
}
|
||||
75
apps/mobile/src/modules/common/hooks/use-theme.tsx
Normal file
75
apps/mobile/src/modules/common/hooks/use-theme.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useColorScheme } from "react-native";
|
||||
import { Uniwind } from "uniwind";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
import { ThemeMode, themes } from "@turbostarter/ui";
|
||||
|
||||
import { appConfig } from "~/config/app";
|
||||
|
||||
import type { ColorVariable, ThemeConfig } from "@turbostarter/ui";
|
||||
|
||||
const useThemeConfig = create<{
|
||||
config: ThemeConfig;
|
||||
setConfig: (config: ThemeConfig) => void;
|
||||
}>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
config: appConfig.theme,
|
||||
setConfig: (config) => set({ config }),
|
||||
}),
|
||||
{
|
||||
name: "theme-config",
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const useTheme = () => {
|
||||
const colorScheme = useColorScheme();
|
||||
const { config, setConfig } = useThemeConfig();
|
||||
|
||||
const isDark = useMemo(
|
||||
() =>
|
||||
config.mode === ThemeMode.DARK ||
|
||||
(config.mode === ThemeMode.SYSTEM && colorScheme === ThemeMode.DARK),
|
||||
[config.mode, colorScheme],
|
||||
);
|
||||
|
||||
const resolvedTheme = useMemo(
|
||||
() => (isDark ? ThemeMode.DARK : ThemeMode.LIGHT),
|
||||
[isDark],
|
||||
);
|
||||
|
||||
const updateTheme = useCallback(() => {
|
||||
Uniwind.setTheme(config.mode);
|
||||
|
||||
const colors = themes[config.color][resolvedTheme];
|
||||
Uniwind.updateCSSVariables(
|
||||
resolvedTheme,
|
||||
Object.entries(colors).reduce(
|
||||
(acc, [key, value]: [string, ColorVariable]) => {
|
||||
const [l, c, h, a] = value;
|
||||
acc[`--${key}`] =
|
||||
a !== undefined
|
||||
? `oklch(${l} ${c} ${h} / ${a * 100}%)`
|
||||
: `oklch(${l} ${c} ${h})`;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
);
|
||||
}, [resolvedTheme, config]);
|
||||
|
||||
useEffect(() => {
|
||||
updateTheme();
|
||||
}, [updateTheme]);
|
||||
|
||||
return {
|
||||
config,
|
||||
setConfig,
|
||||
resolvedTheme,
|
||||
};
|
||||
};
|
||||
71
apps/mobile/src/modules/common/layout/header.tsx
Normal file
71
apps/mobile/src/modules/common/layout/header.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Platform, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { AccountSwitcher } from "~/modules/organization/account-switcher";
|
||||
|
||||
interface BaseHeaderProps {
|
||||
readonly onBack?: () => void;
|
||||
readonly title?: string;
|
||||
}
|
||||
|
||||
export const BaseHeader = ({ onBack, title }: BaseHeaderProps) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
className="bg-background"
|
||||
style={{
|
||||
paddingTop: Platform.select({
|
||||
ios: insets.top + 8,
|
||||
android: insets.top + 16,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<View className="h-12 w-full flex-row items-center justify-center gap-3 pb-1">
|
||||
{onBack && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onPress={() => onBack()}
|
||||
className="absolute bottom-2 left-6"
|
||||
>
|
||||
<Icons.ChevronLeft
|
||||
width={22}
|
||||
height={22}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{title && (
|
||||
<Text className="font-sans-medium mt-0.5 leading-none">{title}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserHeader = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
className="bg-background w-full flex-row items-center justify-between pr-6 pb-1 pl-4"
|
||||
style={{
|
||||
paddingTop: Platform.select({
|
||||
ios: insets.top,
|
||||
android: insets.top + 8,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<AccountSwitcher />
|
||||
<Button size="icon" variant="ghost">
|
||||
<Icons.Bell size={20} className="text-muted-foreground" />
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
69
apps/mobile/src/modules/common/settings-tile.tsx
Normal file
69
apps/mobile/src/modules/common/settings-tile.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Pressable } from "react-native";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { TextClassContext } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
interface SettingsTileProps {
|
||||
readonly icon: React.ElementType;
|
||||
readonly children: React.ReactNode;
|
||||
readonly onPress?: () => void;
|
||||
readonly destructive?: boolean;
|
||||
readonly loading?: boolean;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SettingsTile = ({
|
||||
icon: Icon,
|
||||
onPress,
|
||||
children,
|
||||
destructive,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
}: SettingsTileProps) => {
|
||||
return (
|
||||
<Pressable
|
||||
hitSlop={4}
|
||||
className={cn(
|
||||
"bg-background active:bg-accent dark:active:bg-accent/50 flex-row items-center justify-between gap-4 px-6 py-3.5 transition-colors",
|
||||
{
|
||||
"opacity-50": disabled,
|
||||
},
|
||||
)}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
width={24}
|
||||
height={24}
|
||||
className={cn("text-muted-foreground", {
|
||||
"text-destructive": destructive,
|
||||
})}
|
||||
/>
|
||||
<TextClassContext.Provider
|
||||
value={cn("mr-auto text-base", {
|
||||
"text-destructive": destructive,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</TextClassContext.Provider>
|
||||
|
||||
{loading ? (
|
||||
<Spin>
|
||||
<Icons.Loader2
|
||||
className="text-muted-foreground"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</Spin>
|
||||
) : (
|
||||
<Icons.ChevronRight
|
||||
className="text-muted-foreground"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
39
apps/mobile/src/modules/common/spinner.tsx
Normal file
39
apps/mobile/src/modules/common/spinner.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Portal } from "@rn-primitives/portal";
|
||||
import { Fragment } from "react";
|
||||
import { ActivityIndicator, StyleSheet, View } from "react-native";
|
||||
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
|
||||
|
||||
import { isIOS } from "~/utils/device";
|
||||
|
||||
const FullWindowOverlay = isIOS ? RNFullWindowOverlay : Fragment;
|
||||
|
||||
interface SpinnerProps {
|
||||
readonly modal?: boolean;
|
||||
}
|
||||
|
||||
export const Spinner = ({ modal = true }: SpinnerProps) => {
|
||||
if (!modal) {
|
||||
return (
|
||||
<View
|
||||
style={StyleSheet.absoluteFill}
|
||||
className="bg-background flex-1 items-center justify-center"
|
||||
>
|
||||
<ActivityIndicator size="large" colorClassName="accent-primary" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal name="spinner">
|
||||
<FullWindowOverlay>
|
||||
<View
|
||||
style={StyleSheet.absoluteFill}
|
||||
className="flex-1 items-center justify-center"
|
||||
>
|
||||
<View style={StyleSheet.absoluteFill} className="bg-background/80" />
|
||||
<ActivityIndicator size="large" colorClassName="accent-primary" />
|
||||
</View>
|
||||
</FullWindowOverlay>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
39
apps/mobile/src/modules/common/styled.tsx
Normal file
39
apps/mobile/src/modules/common/styled.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { BlurView as NativeBlurView } from "expo-blur";
|
||||
import { GlassView as NativeGlassView } from "expo-glass-effect";
|
||||
import { Link as NativeLink } from "expo-router";
|
||||
import { ScrollView as NativeScrollView } from "react-native-gesture-handler";
|
||||
import { KeyboardAvoidingView as NativeKeyboardAvoidingView } from "react-native-keyboard-controller";
|
||||
import { SafeAreaView as NativeSafeAreaView } from "react-native-safe-area-context";
|
||||
import { withUniwind } from "uniwind";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { WIDTH } from "~/utils/device";
|
||||
|
||||
export const KeyboardAvoidingView = withUniwind(NativeKeyboardAvoidingView);
|
||||
export const Link = withUniwind(NativeLink);
|
||||
export const ScrollView = withUniwind(NativeScrollView);
|
||||
export const SafeAreaView = withUniwind(NativeSafeAreaView);
|
||||
export const BlurView = withUniwind(NativeBlurView);
|
||||
export const GlassView = withUniwind(NativeGlassView);
|
||||
|
||||
export const TabBarLabel = ({
|
||||
children,
|
||||
focused,
|
||||
}: {
|
||||
children: string;
|
||||
focused: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Text
|
||||
className={cn(
|
||||
"text-muted-foreground text-xs",
|
||||
focused && "text-primary",
|
||||
WIDTH > 640 && "ml-3 text-sm",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
298
apps/mobile/src/modules/common/updates.tsx
Normal file
298
apps/mobile/src/modules/common/updates.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import {
|
||||
useUpdates,
|
||||
reloadAsync,
|
||||
fetchUpdateAsync,
|
||||
checkForUpdateAsync,
|
||||
} from "expo-updates";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { AppState, Platform, View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-mobile/button";
|
||||
import { Icons } from "@turbostarter/ui-mobile/icons";
|
||||
import { Progress } from "@turbostarter/ui-mobile/progress";
|
||||
import { Spin } from "@turbostarter/ui-mobile/spin";
|
||||
import { Text } from "@turbostarter/ui-mobile/text";
|
||||
|
||||
import { BlurView, GlassView } from "./styled";
|
||||
|
||||
import type { ViewProps } from "react-native";
|
||||
|
||||
const UpdatesContext = createContext<{
|
||||
visible: boolean;
|
||||
setVisible: (visible: boolean) => void;
|
||||
}>({
|
||||
visible: false,
|
||||
setVisible: () => void 0,
|
||||
});
|
||||
|
||||
const Wrapper = ({ className, style, ...props }: ViewProps) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const sharedClassName =
|
||||
"absolute left-0 mx-5 gap-3 overflow-hidden rounded-lg border-border border p-5";
|
||||
|
||||
const offset = insets.top + (Platform.select({ ios: 4, android: 8 }) ?? 0);
|
||||
const sharedStyle = {
|
||||
top: offset,
|
||||
};
|
||||
|
||||
if (isLiquidGlassAvailable()) {
|
||||
return (
|
||||
<GlassView
|
||||
className={cn(sharedClassName, className)}
|
||||
style={[sharedStyle, style]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlurView
|
||||
className={cn(sharedClassName, className)}
|
||||
style={[sharedStyle, style]}
|
||||
intensity={200}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Available = () => {
|
||||
const { t } = useTranslation(["marketing", "common"]);
|
||||
const { isUpdatePending } = useUpdates();
|
||||
const { setVisible } = useContext(UpdatesContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className="gap-0.5">
|
||||
<Text className="font-sans-medium text-lg leading-tight">
|
||||
{t("update.available.title")}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{t("update.available.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="grow"
|
||||
onPress={() => setVisible(false)}
|
||||
>
|
||||
<Text>{t("dismiss")}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="grow"
|
||||
onPress={() => {
|
||||
if (isUpdatePending) {
|
||||
return reloadAsync();
|
||||
}
|
||||
|
||||
return fetchUpdateAsync();
|
||||
}}
|
||||
>
|
||||
<Text>{t("install")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Installing = () => {
|
||||
const { t } = useTranslation(["marketing", "common"]);
|
||||
const { downloadProgress, isUpdatePending } = useUpdates();
|
||||
|
||||
useEffect(() => {
|
||||
if (isUpdatePending && (downloadProgress ?? 0) >= 1) {
|
||||
void reloadAsync();
|
||||
}
|
||||
}, [isUpdatePending, downloadProgress]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className="gap-0.5">
|
||||
<View className="flex-row items-center justify-between gap-2">
|
||||
<Text className="font-sans-medium text-lg leading-tight">
|
||||
{t("update.installing.title")}
|
||||
</Text>
|
||||
<Spin>
|
||||
<Icons.Loader2 className="text-primary" size={16} />
|
||||
</Spin>
|
||||
</View>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{t("update.installing.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<Progress value={(downloadProgress ?? 0) * 100} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DownloadError = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const { isUpdatePending, downloadError } = useUpdates();
|
||||
const { setVisible } = useContext(UpdatesContext);
|
||||
|
||||
if (!downloadError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className="gap-0.5">
|
||||
<Text className="font-sans-medium text-lg leading-tight">
|
||||
{t("error.general")}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{downloadError.message}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="grow"
|
||||
onPress={() => setVisible(false)}
|
||||
>
|
||||
<Text>{t("dismiss")}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="grow"
|
||||
onPress={() => {
|
||||
if (isUpdatePending) {
|
||||
return reloadAsync();
|
||||
}
|
||||
|
||||
return fetchUpdateAsync();
|
||||
}}
|
||||
>
|
||||
<Text>{t("tryAgain")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Content = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { visible, setVisible } = useContext(UpdatesContext);
|
||||
const { isUpdateAvailable, isUpdatePending, isDownloading, downloadError } =
|
||||
useUpdates();
|
||||
|
||||
const positionY = useSharedValue(-1000);
|
||||
const startY = useSharedValue(0);
|
||||
const height = useSharedValue(0);
|
||||
const offset = insets.top + (Platform.select({ ios: 4, android: 8 }) ?? 0);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ translateY: withSpring(positionY.value) }],
|
||||
};
|
||||
});
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
.onBegin(() => {
|
||||
startY.value = positionY.value;
|
||||
})
|
||||
.onChange((event) => {
|
||||
const next = startY.value + event.translationY;
|
||||
positionY.value = Math.min(0, next);
|
||||
})
|
||||
.onFinalize((event) => {
|
||||
const threshold = Math.max(64, (height.value + offset) * 0.4);
|
||||
const shouldDismiss =
|
||||
positionY.value < -threshold || event.velocityY < -500;
|
||||
if (shouldDismiss) {
|
||||
positionY.value = withSpring(-(height.value + offset));
|
||||
scheduleOnRN(setVisible, false);
|
||||
} else {
|
||||
positionY.value = withSpring(0);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
positionY.value = withSpring(0);
|
||||
} else {
|
||||
positionY.value = withSpring(
|
||||
height.value > 0 ? -(height.value + offset) : -1000,
|
||||
);
|
||||
}
|
||||
}, [visible, positionY, offset, height]);
|
||||
|
||||
if (!isUpdateAvailable || !isUpdatePending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={pan}>
|
||||
<Animated.View style={[animatedStyle, { zIndex: 50 }]}>
|
||||
<Wrapper
|
||||
onLayout={(e) => {
|
||||
const h = e.nativeEvent.layout.height;
|
||||
height.value = h;
|
||||
if (!visible) {
|
||||
positionY.value = -(h + offset);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<Installing />
|
||||
) : downloadError ? (
|
||||
<DownloadError />
|
||||
) : (
|
||||
<Available />
|
||||
)}
|
||||
</Wrapper>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
};
|
||||
|
||||
export const Updates = () => {
|
||||
const { isUpdateAvailable, isUpdatePending } = useUpdates();
|
||||
const [visible, setVisible] = useState(isUpdateAvailable || isUpdatePending);
|
||||
|
||||
useEffect(() => {
|
||||
if (isUpdateAvailable || isUpdatePending) {
|
||||
setVisible(true);
|
||||
}
|
||||
}, [isUpdateAvailable, isUpdatePending]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (state) => {
|
||||
if (state !== "active") {
|
||||
return;
|
||||
}
|
||||
|
||||
void checkForUpdateAsync()
|
||||
.then(({ isAvailable }) => {
|
||||
setVisible(isAvailable);
|
||||
})
|
||||
.catch(() => {
|
||||
setVisible(false);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UpdatesContext.Provider value={{ visible, setVisible }}>
|
||||
<Content />
|
||||
</UpdatesContext.Provider>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user