Merge pull request #1575 from linkwarden/dev

Dev
This commit is contained in:
Daniel
2026-01-05 18:09:36 +03:30
committed by GitHub
16 changed files with 169 additions and 108 deletions

46
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,46 @@
## What does this PR do?
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
- Fixes #XXXX (GitHub issue number)
## Visual Demo
A visual demonstration is strongly recommended, for both the original and new change **(video / image)**.
#### Video Demo (if applicable):
- Show screen recordings of the issue or feature.
- Demonstrate how to reproduce the issue, the behavior before and after the change.
#### Image Demo (if applicable):
- Add side-by-side screenshots of the original and updated change.
- Highlight any significant change(s).
## AI Assistance (Required)
We allow AI-assisted development, but reviewers need transparency to assess risk, maintainability, and correctness.
#### AI usage level (check one)
- [ ] None (no AI used)
- [ ] Light (spellcheck/rewording/comments/docs only)
- [ ] Medium (AI suggested small code changes/snippets that I adapted)
- [ ] Heavy (AI significantly shaped the implementation or architecture)
#### Which tool(s) where used?
- e.g., ChatGPT, Copilot, Cursor, etc.
## What was verified by the author?
<!-- Add what you personally checked to ensure correctness and safety. -->
- [ ] I reviewed **and** understood all AI/human generated code
- [ ] I validated behavior locally (tests/manual verification)
- [ ] I checked edge cases and failure modes
## Submission Acknowledgement
- [ ] I acknowledge that a decent size PR without self-review might be rejected

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Linkwarden", "name": "Linkwarden",
"slug": "linkwarden", "slug": "linkwarden",
"version": "1.0.0", "version": "1.0.1",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "linkwarden", "scheme": "linkwarden",

View File

@@ -44,7 +44,7 @@ export default function CollectionsScreen() {
collapsableChildren={false} collapsableChildren={false}
> >
{collections.isLoading ? ( {collections.isLoading ? (
<View className="flex justify-center h-full items-center"> <View className="flex justify-center h-screen items-center">
<ActivityIndicator size="large" /> <ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text> <Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View> </View>

View File

@@ -27,8 +27,8 @@ export default function Layout() {
Platform.OS === "ios" Platform.OS === "ios"
? "transparent" ? "transparent"
: colorScheme === "dark" : colorScheme === "dark"
? rawTheme["dark"]["base-100"] ? rawTheme["dark"]["base-100"]
: "white", : "white",
}, },
}} }}
> >

View File

@@ -1,4 +1,11 @@
import { Platform, ScrollView, StyleSheet } from "react-native"; import {
ActivityIndicator,
Platform,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useDashboardData } from "@linkwarden/router/dashboardData"; import { useDashboardData } from "@linkwarden/router/dashboardData";
import useAuthStore from "@/store/auth"; import useAuthStore from "@/store/auth";
@@ -53,22 +60,36 @@ export default function DashboardScreen() {
}); });
}, [dashboardSections]); }, [dashboardSections]);
const [pullRefreshing, setPullRefreshing] = useState(false);
const onRefresh = async () => {
setPullRefreshing(true);
try {
await Promise.all([
dashboardData.refetch(),
userData.refetch(),
collectionsData.refetch(),
tagsData.refetch(),
]);
} finally {
setPullRefreshing(false);
}
};
if (orderedSections.length === 0 && dashboardData.isLoading)
return (
<View className="flex justify-center h-screen items-center bg-base-100">
<ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>
);
return ( return (
<ScrollView <ScrollView
refreshControl={ refreshControl={
<Spinner <Spinner
refreshing={ refreshing={pullRefreshing}
dashboardData.isRefetching || onRefresh={onRefresh}
userData.isRefetching ||
collectionsData.isRefetching ||
tagsData.isRefetching
}
onRefresh={() => {
dashboardData.refetch();
userData.refetch();
collectionsData.refetch();
tagsData.refetch();
}}
progressBackgroundColor={ progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"] rawTheme[colorScheme as ThemeName]["base-200"]
} }

View File

@@ -42,7 +42,7 @@ export default function TagsScreen() {
collapsableChildren={false} collapsableChildren={false}
> >
{tags.isLoading ? ( {tags.isLoading ? (
<View className="flex justify-center h-full items-center"> <View className="flex justify-center h-screen items-center">
<ActivityIndicator size="large" /> <ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text> <Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View> </View>

View File

@@ -20,56 +20,58 @@ export default function HomeScreen() {
return ( return (
<Animated.View <Animated.View
entering={SlideInDown.springify().damping(100).stiffness(300)} entering={SlideInDown.springify().damping(100).stiffness(300)}
className="flex-col justify-end h-full bg-primary relative" className="flex-col justify-end h-full"
> >
<View className="my-auto"> <View className="h-full bg-primary relative">
<Image <View className="my-auto">
source={require("@/assets/images/linkwarden.png")} <Image
className="w-[120px] h-[120px] mx-auto" source={require("@/assets/images/linkwarden.png")}
/> className="w-[120px] h-[120px] mx-auto"
<Text className="text-base-100 text-4xl font-semibold mt-7 mx-auto"> />
Linkwarden <Text className="text-base-100 text-4xl font-semibold mt-7 mx-auto">
</Text> Linkwarden
</View> </Text>
<View> </View>
<Text className="text-base-100 text-xl text-center font-semibold mx-4 mt-3"> <View>
Welcome to the official mobile app for Linkwarden! <Text className="text-base-100 text-xl text-center font-semibold mx-4 mt-3">
</Text> Welcome to the official mobile app for Linkwarden!
</Text>
<Text className="text-base-100 text-xl text-center mx-4 mt-3"> <Text className="text-base-100 text-xl text-center mx-4 mt-3">
Expect regular improvements and new features as we continue refining Expect regular improvements and new features as we continue refining
the experience. the experience.
</Text> </Text>
</View>
<Svg
viewBox="0 0 1440 320"
width={Dimensions.get("screen").width}
height={Dimensions.get("screen").width * (320 / 1440) + 2}
>
<Path
fill={rawTheme[colorScheme as ThemeName]["base-100"]}
fill-opacity="1"
d="M0,256L48,234.7C96,213,192,171,288,176C384,181,480,235,576,266.7C672,299,768,309,864,277.3C960,245,1056,171,1152,122.7C1248,75,1344,53,1392,42.7L1440,32L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"
/>
</Svg>
<SafeAreaView
edges={["bottom"]}
className="flex-col justify-end h-auto duration-100 pt-10 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4"
>
<Button
variant="accent"
size="lg"
onPress={() => router.navigate("/login")}
>
<Text className="text-white text-xl">Get Started</Text>
</Button>
<TouchableOpacity
className="w-fit mx-auto"
onPress={() => SheetManager.show("support-sheet")}
>
<Text className="text-neutral text-center w-fit">Need help?</Text>
</TouchableOpacity>
</SafeAreaView>
</View> </View>
<Svg
viewBox="0 0 1440 320"
width={Dimensions.get("screen").width}
height={Dimensions.get("screen").width * (320 / 1440) + 2}
>
<Path
fill={rawTheme[colorScheme as ThemeName]["base-100"]}
fill-opacity="1"
d="M0,256L48,234.7C96,213,192,171,288,176C384,181,480,235,576,266.7C672,299,768,309,864,277.3C960,245,1056,171,1152,122.7C1248,75,1344,53,1392,42.7L1440,32L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"
/>
</Svg>
<SafeAreaView
edges={["bottom"]}
className="flex-col justify-end h-auto duration-100 pt-10 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4"
>
<Button
variant="accent"
size="lg"
onPress={() => router.navigate("/login")}
>
<Text className="text-white text-xl">Get Started</Text>
</Button>
<TouchableOpacity
className="w-fit mx-auto"
onPress={() => SheetManager.show("support-sheet")}
>
<Text className="text-neutral text-center w-fit">Need help?</Text>
</TouchableOpacity>
</SafeAreaView>
</Animated.View> </Animated.View>
); );
} }

View File

@@ -1,4 +1,4 @@
import { Alert, Platform, Text, View } from "react-native"; import { Alert, Text, View } from "react-native";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import ActionSheet, { ActionSheetRef } from "react-native-actions-sheet"; import ActionSheet, { ActionSheetRef } from "react-native-actions-sheet";
import Input from "@/components/ui/Input"; import Input from "@/components/ui/Input";

View File

@@ -1,4 +1,4 @@
import { View, Text, Alert, Platform } from "react-native"; import { View, Text, Alert } from "react-native";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import ActionSheet, { import ActionSheet, {
FlatList, FlatList,

View File

@@ -1,4 +1,4 @@
import { Alert, Platform, Text, View } from "react-native"; import { Alert, Text, View } from "react-native";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import ActionSheet, { ActionSheetRef } from "react-native-actions-sheet"; import ActionSheet, { ActionSheetRef } from "react-native-actions-sheet";
import Input from "@/components/ui/Input"; import Input from "@/components/ui/Input";

View File

@@ -28,7 +28,7 @@ export default function Links({ links, data }: Props) {
const [promptedRefetch, setPromptedRefetch] = useState(false); const [promptedRefetch, setPromptedRefetch] = useState(false);
return data.isLoading ? ( return data.isLoading ? (
<View className="flex justify-center h-full items-center"> <View className="flex justify-center h-screen items-center">
<ActivityIndicator size="large" /> <ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text> <Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View> </View>

View File

@@ -1,17 +0,0 @@
import { PropsWithChildren } from "react";
import { IconSymbol } from "../ui/IconSymbol";
import ModalBase from "../ModalBase";
import { Text } from "react-native";
type Props = PropsWithChildren<{
isVisible: boolean;
onClose: () => void;
}>;
export default function AddLink({ isVisible, onClose }: Props) {
return (
// <ModalBase isVisible={isVisible} onClose={onClose}>
<Text>Hi</Text>
// </ModalBase>
);
}

View File

@@ -20,6 +20,7 @@
} }
}, },
"production": { "production": {
"corepack": true,
"distribution": "store", "distribution": "store",
"autoIncrement": true, "autoIncrement": true,
"channel": "production" "channel": "production"

View File

@@ -1,7 +1,7 @@
{ {
"name": "@linkwarden/mobile", "name": "@linkwarden/mobile",
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"android": "expo run:android", "android": "expo run:android",

View File

@@ -3,13 +3,10 @@ import * as SecureStore from "expo-secure-store";
import { router } from "expo-router"; import { router } from "expo-router";
import { MobileAuth } from "@linkwarden/types"; import { MobileAuth } from "@linkwarden/types";
import { Alert } from "react-native"; import { Alert } from "react-native";
import * as FileSystem from "expo-file-system";
import { queryClient } from "@/lib/queryClient"; import { queryClient } from "@/lib/queryClient";
import { mmkvPersister } from "@/lib/queryPersister"; import { mmkvPersister } from "@/lib/queryPersister";
import { clearCache } from "@/lib/cache"; import { clearCache } from "@/lib/cache";
const CACHE_DIR = FileSystem.documentDirectory + "archivedData/";
type AuthStore = { type AuthStore = {
auth: MobileAuth; auth: MobileAuth;
signIn: ( signIn: (
@@ -78,31 +75,42 @@ const useAuthStore = create<AuthStore>((set) => ({
} }
}); });
} else { } else {
await fetch(instance + "/api/v1/session", { try {
method: "POST", const res = await Promise.race([
body: JSON.stringify({ username, password }), fetch(`${instance}/api/v1/session`, {
headers: { method: "POST",
"Content-Type": "application/json", body: JSON.stringify({ username, password }),
}, headers: { "Content-Type": "application/json" },
}).then(async (res) => { }),
new Promise<Response>((_, reject) =>
setTimeout(() => reject(new Error("TIMEOUT")), 30000)
),
]);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
const session = (data as any).response.token; const session = (data as any).response.token;
await SecureStore.setItemAsync("TOKEN", session); await SecureStore.setItemAsync("TOKEN", session);
await SecureStore.setItemAsync("INSTANCE", instance); await SecureStore.setItemAsync("INSTANCE", instance);
set({ set({ auth: { session, instance, status: "authenticated" } });
auth: {
session,
instance,
status: "authenticated",
},
});
router.replace("/(tabs)/dashboard"); router.replace("/(tabs)/dashboard");
} else { } else {
Alert.alert("Error", "Invalid credentials"); Alert.alert("Error", "Invalid credentials");
} }
}); } catch (err: any) {
if (err?.message === "TIMEOUT") {
Alert.alert(
"Request timed out",
"Unable to reach the server in time. Please check your network configuration and try again."
);
} else {
Alert.alert(
"Network error",
"Could not connect to the server. Please check your network configuration and try again."
);
}
}
} }
}, },
signOut: async () => { signOut: async () => {

View File

@@ -15,13 +15,13 @@ const useDataStore = create<DataStore>((set, get) => ({
hasShareIntent: false, hasShareIntent: false,
url: "", url: "",
}, },
theme: "light", theme: "system",
preferredBrowser: "app", preferredBrowser: "app",
}, },
setData: async () => { setData: async () => {
const dataString = JSON.parse((await AsyncStorage.getItem("data")) || "{}"); const dataString = JSON.parse((await AsyncStorage.getItem("data")) || "{}");
colorScheme.set(dataString.theme || "light"); colorScheme.set(dataString.theme || "system");
if (dataString) if (dataString)
set((state) => ({ data: { ...state.data, ...dataString } })); set((state) => ({ data: { ...state.data, ...dataString } }));