openuispec 0.1.44 → 0.1.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -55
- package/cli/init.ts +14 -3
- package/examples/social-app/.mcp.json +10 -0
- package/examples/social-app/AGENTS.md +105 -0
- package/examples/social-app/CLAUDE.md +105 -0
- package/examples/social-app/README.md +19 -0
- package/examples/social-app/backend/.gitkeep +1 -0
- package/examples/social-app/generated/android/social-app/app/build.gradle.kts +92 -0
- package/examples/social-app/generated/android/social-app/app/src/main/AndroidManifest.xml +26 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/AppContainer.kt +20 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/MainActivity.kt +35 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/SocialAppApplication.kt +13 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/data/MockData.kt +98 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/data/preferences/AppPreferences.kt +19 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/data/preferences/DataStorePreferencesRepository.kt +68 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/data/preferences/PreferencesRepository.kt +15 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/model/Models.kt +34 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/MainShell.kt +390 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/components/Components.kt +234 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/components/ContractPrimitives.kt +641 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/navigation/RootComponent.kt +113 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/ChatDetailScreen.kt +212 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/CreatePostScreen.kt +113 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/DiscoverScreen.kt +137 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/EditProfileScreen.kt +180 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/HomeFeedScreen.kt +157 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/MessagesInboxScreen.kt +85 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/NotificationsScreen.kt +74 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/PostDetailScreen.kt +293 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/ProfileSelfScreen.kt +116 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/SearchResultsScreen.kt +161 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/SettingsScreen.kt +162 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/SettingsStore.kt +95 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/UserProfileScreen.kt +123 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/theme/Color.kt +33 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/theme/Shape.kt +41 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/theme/Spacing.kt +20 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/theme/Theme.kt +82 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/theme/Type.kt +60 -0
- package/examples/social-app/generated/android/social-app/app/src/main/res/drawable/ic_launcher_foreground.xml +9 -0
- package/examples/social-app/generated/android/social-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/examples/social-app/generated/android/social-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +5 -0
- package/examples/social-app/generated/android/social-app/app/src/main/res/values/strings.xml +91 -0
- package/examples/social-app/generated/android/social-app/app/src/main/res/values/themes.xml +10 -0
- package/examples/social-app/generated/android/social-app/app/src/main/res/values-ru/strings.xml +79 -0
- package/examples/social-app/generated/android/social-app/app/src/main/res/values-uz/strings.xml +79 -0
- package/examples/social-app/generated/android/social-app/app/src/main/xml/AndroidManifest.xml +23 -0
- package/examples/social-app/generated/android/social-app/build.gradle.kts +6 -0
- package/examples/social-app/generated/android/social-app/gradle/libs.versions.toml +48 -0
- package/examples/social-app/generated/android/social-app/gradle/wrapper/gradle-wrapper.properties +8 -0
- package/examples/social-app/generated/android/social-app/gradle.properties +11 -0
- package/examples/social-app/generated/android/social-app/gradlew +25 -0
- package/examples/social-app/generated/android/social-app/settings.gradle.kts +23 -0
- package/examples/social-app/generated/web/social-app/index.html +12 -0
- package/examples/social-app/generated/web/social-app/package-lock.json +2517 -0
- package/examples/social-app/generated/web/social-app/package.json +27 -0
- package/examples/social-app/generated/web/social-app/src/app/App.tsx +58 -0
- package/examples/social-app/generated/web/social-app/src/components/Shell.tsx +247 -0
- package/examples/social-app/generated/web/social-app/src/components/cards.tsx +317 -0
- package/examples/social-app/generated/web/social-app/src/components/ui.tsx +328 -0
- package/examples/social-app/generated/web/social-app/src/flows/CreatePostFlow.tsx +86 -0
- package/examples/social-app/generated/web/social-app/src/i18n.tsx +59 -0
- package/examples/social-app/generated/web/social-app/src/lib/icons.tsx +85 -0
- package/examples/social-app/generated/web/social-app/src/lib/tokens.ts +70 -0
- package/examples/social-app/generated/web/social-app/src/lib/utils.ts +97 -0
- package/examples/social-app/generated/web/social-app/src/locales/en.json +67 -0
- package/examples/social-app/generated/web/social-app/src/locales/ru.json +67 -0
- package/examples/social-app/generated/web/social-app/src/locales/uz.json +67 -0
- package/examples/social-app/generated/web/social-app/src/main.tsx +16 -0
- package/examples/social-app/generated/web/social-app/src/screens/ChatDetailScreen.tsx +90 -0
- package/examples/social-app/generated/web/social-app/src/screens/DiscoverScreen.tsx +86 -0
- package/examples/social-app/generated/web/social-app/src/screens/EditProfileScreen.tsx +57 -0
- package/examples/social-app/generated/web/social-app/src/screens/HomeFeedScreen.tsx +113 -0
- package/examples/social-app/generated/web/social-app/src/screens/MessagesInboxScreen.tsx +52 -0
- package/examples/social-app/generated/web/social-app/src/screens/NotificationsScreen.tsx +41 -0
- package/examples/social-app/generated/web/social-app/src/screens/PostDetailScreen.tsx +115 -0
- package/examples/social-app/generated/web/social-app/src/screens/ProfileSelfScreen.tsx +57 -0
- package/examples/social-app/generated/web/social-app/src/screens/ProfileUserScreen.tsx +76 -0
- package/examples/social-app/generated/web/social-app/src/screens/SearchResultsScreen.tsx +96 -0
- package/examples/social-app/generated/web/social-app/src/screens/SettingsScreen.tsx +77 -0
- package/examples/social-app/generated/web/social-app/src/state/store.ts +592 -0
- package/examples/social-app/generated/web/social-app/src/styles.css +124 -0
- package/examples/social-app/generated/web/social-app/src/vite-env.d.ts +1 -0
- package/examples/social-app/generated/web/social-app/tsconfig.json +22 -0
- package/examples/social-app/generated/web/social-app/tsconfig.node.json +13 -0
- package/examples/social-app/generated/web/social-app/tsconfig.node.tsbuildinfo +1 -0
- package/examples/social-app/generated/web/social-app/tsconfig.tsbuildinfo +1 -0
- package/examples/social-app/generated/web/social-app/vite.config.d.ts +2 -0
- package/examples/social-app/generated/web/social-app/vite.config.js +6 -0
- package/examples/social-app/generated/web/social-app/vite.config.ts +7 -0
- package/examples/social-app/openuispec/README.md +56 -0
- package/examples/social-app/openuispec/contracts/.gitkeep +0 -0
- package/examples/social-app/openuispec/contracts/action_trigger.yaml +73 -0
- package/examples/social-app/openuispec/contracts/collection.yaml +43 -0
- package/examples/social-app/openuispec/contracts/data_display.yaml +47 -0
- package/examples/social-app/openuispec/contracts/feedback.yaml +49 -0
- package/examples/social-app/openuispec/contracts/input_field.yaml +41 -0
- package/examples/social-app/openuispec/contracts/nav_container.yaml +34 -0
- package/examples/social-app/openuispec/contracts/surface.yaml +41 -0
- package/examples/social-app/openuispec/flows/.gitkeep +0 -0
- package/examples/social-app/openuispec/flows/create_post.yaml +66 -0
- package/examples/social-app/openuispec/locales/.gitkeep +0 -0
- package/examples/social-app/openuispec/locales/en.json +67 -0
- package/examples/social-app/openuispec/locales/ru.json +67 -0
- package/examples/social-app/openuispec/locales/uz.json +67 -0
- package/examples/social-app/openuispec/openuispec.yaml +214 -0
- package/examples/social-app/openuispec/platform/.gitkeep +0 -0
- package/examples/social-app/openuispec/platform/android.yaml +30 -0
- package/examples/social-app/openuispec/platform/ios.yaml +19 -0
- package/examples/social-app/openuispec/platform/web.yaml +23 -0
- package/examples/social-app/openuispec/screens/.gitkeep +0 -0
- package/examples/social-app/openuispec/screens/chat_detail.yaml +53 -0
- package/examples/social-app/openuispec/screens/discover.yaml +78 -0
- package/examples/social-app/openuispec/screens/edit_profile.yaml +78 -0
- package/examples/social-app/openuispec/screens/home_feed.yaml +123 -0
- package/examples/social-app/openuispec/screens/messages_inbox.yaml +43 -0
- package/examples/social-app/openuispec/screens/notifications.yaml +29 -0
- package/examples/social-app/openuispec/screens/post_detail.yaml +86 -0
- package/examples/social-app/openuispec/screens/profile_self.yaml +53 -0
- package/examples/social-app/openuispec/screens/profile_user.yaml +60 -0
- package/examples/social-app/openuispec/screens/search_results.yaml +62 -0
- package/examples/social-app/openuispec/screens/settings.yaml +94 -0
- package/examples/social-app/openuispec/tokens/.gitkeep +0 -0
- package/examples/social-app/openuispec/tokens/color.yaml +76 -0
- package/examples/social-app/openuispec/tokens/elevation.yaml +31 -0
- package/examples/social-app/openuispec/tokens/icons.yaml +147 -0
- package/examples/social-app/openuispec/tokens/layout.yaml +37 -0
- package/examples/social-app/openuispec/tokens/motion.yaml +28 -0
- package/examples/social-app/openuispec/tokens/spacing.yaml +19 -0
- package/examples/social-app/openuispec/tokens/themes.yaml +31 -0
- package/examples/social-app/openuispec/tokens/typography.yaml +50 -0
- package/examples/social-app/package.json +12 -0
- package/mcp-server/index.ts +69 -0
- package/package.json +1 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useDeferredValue, useState } from "react";
|
|
2
|
+
import { useNavigate } from "react-router";
|
|
3
|
+
import { useI18n } from "../i18n";
|
|
4
|
+
import { ConversationCard } from "../components/cards";
|
|
5
|
+
import { EmptyState, ErrorState, ScreenScaffold, SkeletonList, TextField } from "../components/ui";
|
|
6
|
+
import { selectConversations, useAppStore } from "../state/store";
|
|
7
|
+
import { useSimulatedLoading, useUiScenario } from "../lib/utils";
|
|
8
|
+
|
|
9
|
+
export function MessagesInboxScreen() {
|
|
10
|
+
const { t } = useI18n();
|
|
11
|
+
const navigate = useNavigate();
|
|
12
|
+
const scenario = useUiScenario();
|
|
13
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
14
|
+
const deferred = useDeferredValue(searchQuery);
|
|
15
|
+
const loading = useSimulatedLoading(`messages-${deferred}`, scenario);
|
|
16
|
+
const state = useAppStore();
|
|
17
|
+
const items = scenario === "empty" ? [] : selectConversations(state, deferred);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<ScreenScaffold title="Messages" subtitle="Conversation search, unread indicators, and direct routes into chat threads.">
|
|
21
|
+
<TextField
|
|
22
|
+
label={t("messages.search_placeholder")}
|
|
23
|
+
value={searchQuery}
|
|
24
|
+
onValueChange={setSearchQuery}
|
|
25
|
+
placeholder={t("messages.search_placeholder")}
|
|
26
|
+
/>
|
|
27
|
+
|
|
28
|
+
{scenario === "error" ? (
|
|
29
|
+
<ErrorState title="Inbox unavailable" description="The inbox query is in an error state. Remove `?ui=error` to restore it." />
|
|
30
|
+
) : loading ? (
|
|
31
|
+
<SkeletonList count={5} />
|
|
32
|
+
) : items.length === 0 ? (
|
|
33
|
+
<EmptyState title="No conversations" description={t("messages.empty_inbox")} />
|
|
34
|
+
) : (
|
|
35
|
+
<div className="space-y-3">
|
|
36
|
+
{items.map((item) =>
|
|
37
|
+
item.participant ? (
|
|
38
|
+
<ConversationCard
|
|
39
|
+
key={item.conversation.id}
|
|
40
|
+
user={item.participant}
|
|
41
|
+
excerpt={item.lastMessage?.body ?? "No messages yet"}
|
|
42
|
+
unreadCount={item.unreadCount}
|
|
43
|
+
timestamp={item.lastMessage?.createdAt}
|
|
44
|
+
onOpen={() => navigate(`/chat/${item.conversation.id}`)}
|
|
45
|
+
/>
|
|
46
|
+
) : null,
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
)}
|
|
50
|
+
</ScreenScaffold>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useNavigate } from "react-router";
|
|
2
|
+
import { useI18n } from "../i18n";
|
|
3
|
+
import { NotificationCard } from "../components/cards";
|
|
4
|
+
import { EmptyState, ErrorState, ScreenScaffold, SkeletonList } from "../components/ui";
|
|
5
|
+
import { selectNotifications, useAppStore } from "../state/store";
|
|
6
|
+
import { useSimulatedLoading, useUiScenario } from "../lib/utils";
|
|
7
|
+
|
|
8
|
+
export function NotificationsScreen() {
|
|
9
|
+
const { t } = useI18n();
|
|
10
|
+
const navigate = useNavigate();
|
|
11
|
+
const scenario = useUiScenario();
|
|
12
|
+
const loading = useSimulatedLoading("notifications", scenario);
|
|
13
|
+
const state = useAppStore();
|
|
14
|
+
const notifications = scenario === "empty" ? [] : selectNotifications(state);
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<ScreenScaffold title={t("nav.notifications")} subtitle="Mark-as-read interactions wired to the local mock state.">
|
|
18
|
+
{scenario === "error" ? (
|
|
19
|
+
<ErrorState title="Notifications unavailable" description="The notifications request failed. Remove `?ui=error` to recover." />
|
|
20
|
+
) : loading ? (
|
|
21
|
+
<SkeletonList count={6} />
|
|
22
|
+
) : notifications.length === 0 ? (
|
|
23
|
+
<EmptyState title="Nothing new" description={t("notifications.empty")} />
|
|
24
|
+
) : (
|
|
25
|
+
<div className="space-y-3">
|
|
26
|
+
{notifications.map((item) => (
|
|
27
|
+
<NotificationCard
|
|
28
|
+
key={item.id}
|
|
29
|
+
item={item}
|
|
30
|
+
actor={item.actor}
|
|
31
|
+
onOpen={() => {
|
|
32
|
+
state.markNotificationRead(item.id);
|
|
33
|
+
navigate(item.postId ? `/posts/${item.postId}` : "/profile");
|
|
34
|
+
}}
|
|
35
|
+
/>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
)}
|
|
39
|
+
</ScreenScaffold>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useNavigate, useParams } from "react-router";
|
|
3
|
+
import { useI18n } from "../i18n";
|
|
4
|
+
import { CommentCard, PostCard } from "../components/cards";
|
|
5
|
+
import { ActionButton, EmptyState, ErrorState, ScreenScaffold, SectionTitle, SkeletonList, TextField } from "../components/ui";
|
|
6
|
+
import { useSimulatedLoading, useUiScenario } from "../lib/utils";
|
|
7
|
+
import { selectCommentsByPost, selectPostById, selectUserById, useAppStore } from "../state/store";
|
|
8
|
+
|
|
9
|
+
export function PostDetailScreen() {
|
|
10
|
+
const { t } = useI18n();
|
|
11
|
+
const navigate = useNavigate();
|
|
12
|
+
const { postId = "" } = useParams();
|
|
13
|
+
const scenario = useUiScenario();
|
|
14
|
+
const loading = useSimulatedLoading(`post-${postId}`, scenario);
|
|
15
|
+
const state = useAppStore();
|
|
16
|
+
const [commentText, setCommentText] = useState("");
|
|
17
|
+
const post = scenario === "empty" ? undefined : selectPostById(state, postId);
|
|
18
|
+
const comments = scenario === "empty" ? [] : selectCommentsByPost(state, postId);
|
|
19
|
+
|
|
20
|
+
if (scenario === "error") {
|
|
21
|
+
return (
|
|
22
|
+
<ScreenScaffold title="Post Detail">
|
|
23
|
+
<ErrorState title="Post unavailable" description="The detail request failed. Remove `?ui=error` to return to the normal state." />
|
|
24
|
+
</ScreenScaffold>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (loading) {
|
|
29
|
+
return (
|
|
30
|
+
<ScreenScaffold title="Post Detail">
|
|
31
|
+
<SkeletonList count={1} tall />
|
|
32
|
+
</ScreenScaffold>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!post) {
|
|
37
|
+
return (
|
|
38
|
+
<ScreenScaffold title="Post Detail">
|
|
39
|
+
<EmptyState title="Missing post" description="This post could not be found in the local mock data." />
|
|
40
|
+
</ScreenScaffold>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const author = selectUserById(state, post.authorId);
|
|
45
|
+
if (!author) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<ScreenScaffold title="Post Detail" subtitle={`Published ${new Date(post.publishedAt).toLocaleDateString()}`}>
|
|
51
|
+
<PostCard
|
|
52
|
+
post={post}
|
|
53
|
+
author={author}
|
|
54
|
+
hero
|
|
55
|
+
onAuthor={() => navigate(`/u/${author.id}`)}
|
|
56
|
+
onLike={() => state.toggleLike(post.id)}
|
|
57
|
+
onSave={() => state.toggleSave(post.id)}
|
|
58
|
+
/>
|
|
59
|
+
|
|
60
|
+
<section className="space-y-4">
|
|
61
|
+
<ActionButton variant="secondary" icon={post.liked ? "like_fill" : "like"} onClick={() => state.toggleLike(post.id)}>
|
|
62
|
+
{t("post.like_action")}
|
|
63
|
+
</ActionButton>
|
|
64
|
+
</section>
|
|
65
|
+
|
|
66
|
+
<section className="space-y-4">
|
|
67
|
+
<SectionTitle>{t("post.comments_header")}</SectionTitle>
|
|
68
|
+
{comments.length === 0 ? (
|
|
69
|
+
<EmptyState title="No comments yet" description={t("post.no_comments")} />
|
|
70
|
+
) : (
|
|
71
|
+
<div className="space-y-3">
|
|
72
|
+
{comments.map((comment) => {
|
|
73
|
+
const commentAuthor = selectUserById(state, comment.authorId);
|
|
74
|
+
if (!commentAuthor) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return (
|
|
78
|
+
<CommentCard
|
|
79
|
+
key={comment.id}
|
|
80
|
+
comment={comment}
|
|
81
|
+
author={commentAuthor}
|
|
82
|
+
onAuthor={() => navigate(`/u/${commentAuthor.id}`)}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
})}
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</section>
|
|
89
|
+
|
|
90
|
+
<TextField
|
|
91
|
+
label={t("post.comment_placeholder")}
|
|
92
|
+
value={commentText}
|
|
93
|
+
multiline
|
|
94
|
+
onValueChange={setCommentText}
|
|
95
|
+
placeholder={t("post.comment_placeholder")}
|
|
96
|
+
trailingAction={
|
|
97
|
+
<ActionButton
|
|
98
|
+
variant="primary"
|
|
99
|
+
icon="send"
|
|
100
|
+
onClick={() => {
|
|
101
|
+
if (!commentText.trim()) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
state.addComment(post.id, commentText.trim());
|
|
105
|
+
setCommentText("");
|
|
106
|
+
state.showToast(t("post.comment_sent"));
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
Send
|
|
110
|
+
</ActionButton>
|
|
111
|
+
}
|
|
112
|
+
/>
|
|
113
|
+
</ScreenScaffold>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useNavigate } from "react-router";
|
|
2
|
+
import { useI18n } from "../i18n";
|
|
3
|
+
import { PostCard, ProfileHero } from "../components/cards";
|
|
4
|
+
import { ActionButton, EmptyState, ScreenScaffold, SectionTitle, SkeletonList } from "../components/ui";
|
|
5
|
+
import { selectCurrentUser, selectProfilePosts, selectUserById, useAppStore } from "../state/store";
|
|
6
|
+
import { useSimulatedLoading, useUiScenario } from "../lib/utils";
|
|
7
|
+
|
|
8
|
+
export function ProfileSelfScreen() {
|
|
9
|
+
const { t } = useI18n();
|
|
10
|
+
const navigate = useNavigate();
|
|
11
|
+
const scenario = useUiScenario();
|
|
12
|
+
const loading = useSimulatedLoading("profile-self", scenario);
|
|
13
|
+
const state = useAppStore();
|
|
14
|
+
const user = selectCurrentUser(state);
|
|
15
|
+
const posts = scenario === "empty" ? [] : selectProfilePosts(state, user.id);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<ScreenScaffold title={t("nav.profile")} subtitle="Your own profile with edit action and authored posts.">
|
|
19
|
+
{loading ? (
|
|
20
|
+
<SkeletonList count={2} tall />
|
|
21
|
+
) : (
|
|
22
|
+
<ProfileHero
|
|
23
|
+
user={user}
|
|
24
|
+
action={
|
|
25
|
+
<ActionButton variant="secondary" icon="edit" onClick={() => navigate("/profile/edit")}>
|
|
26
|
+
{t("profile.edit_button")}
|
|
27
|
+
</ActionButton>
|
|
28
|
+
}
|
|
29
|
+
/>
|
|
30
|
+
)}
|
|
31
|
+
|
|
32
|
+
<section className="space-y-4">
|
|
33
|
+
<SectionTitle>{t("profile.posts_header")}</SectionTitle>
|
|
34
|
+
{loading ? (
|
|
35
|
+
<SkeletonList count={3} />
|
|
36
|
+
) : posts.length === 0 ? (
|
|
37
|
+
<EmptyState title="No posts yet" description={t("profile.no_posts_self")} />
|
|
38
|
+
) : (
|
|
39
|
+
<div className="space-y-4">
|
|
40
|
+
{posts.map((post) => (
|
|
41
|
+
<div key={post.id} className="rounded-card border border-[var(--color-border-default)] bg-[var(--color-surface-secondary)] p-4 shadow-sm">
|
|
42
|
+
<PostCard
|
|
43
|
+
post={post}
|
|
44
|
+
author={selectUserById(state, post.authorId) ?? user}
|
|
45
|
+
onOpen={() => navigate(`/posts/${post.id}`)}
|
|
46
|
+
onAuthor={() => navigate(`/u/${user.id}`)}
|
|
47
|
+
onLike={() => state.toggleLike(post.id)}
|
|
48
|
+
onSave={() => state.toggleSave(post.id)}
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
))}
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
</section>
|
|
55
|
+
</ScreenScaffold>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useNavigate, useParams } from "react-router";
|
|
2
|
+
import { useI18n } from "../i18n";
|
|
3
|
+
import { PostCard, ProfileHero } from "../components/cards";
|
|
4
|
+
import { ActionButton, EmptyState, ErrorState, ScreenScaffold, SectionTitle, SkeletonList } from "../components/ui";
|
|
5
|
+
import { selectProfilePosts, selectUserById, useAppStore } from "../state/store";
|
|
6
|
+
import { useSimulatedLoading, useUiScenario } from "../lib/utils";
|
|
7
|
+
|
|
8
|
+
export function ProfileUserScreen() {
|
|
9
|
+
const { t } = useI18n();
|
|
10
|
+
const navigate = useNavigate();
|
|
11
|
+
const { userId = "" } = useParams();
|
|
12
|
+
const scenario = useUiScenario();
|
|
13
|
+
const loading = useSimulatedLoading(`profile-${userId}`, scenario);
|
|
14
|
+
const state = useAppStore();
|
|
15
|
+
const user = scenario === "empty" ? undefined : selectUserById(state, userId);
|
|
16
|
+
const posts = scenario === "empty" ? [] : selectProfilePosts(state, userId);
|
|
17
|
+
|
|
18
|
+
if (scenario === "error") {
|
|
19
|
+
return (
|
|
20
|
+
<ScreenScaffold title="Profile">
|
|
21
|
+
<ErrorState title="Profile unavailable" description="The profile request failed. Remove `?ui=error` to restore it." />
|
|
22
|
+
</ScreenScaffold>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (loading) {
|
|
27
|
+
return (
|
|
28
|
+
<ScreenScaffold title="Profile">
|
|
29
|
+
<SkeletonList count={3} tall />
|
|
30
|
+
</ScreenScaffold>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!user) {
|
|
35
|
+
return (
|
|
36
|
+
<ScreenScaffold title="Profile">
|
|
37
|
+
<EmptyState title="Profile missing" description="The requested user does not exist in the local dataset." />
|
|
38
|
+
</ScreenScaffold>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<ScreenScaffold title={user.displayName} subtitle={`@${user.handle}`}>
|
|
44
|
+
<ProfileHero
|
|
45
|
+
user={user}
|
|
46
|
+
action={
|
|
47
|
+
<ActionButton variant="primary" onClick={() => state.followUser(user.id)}>
|
|
48
|
+
{t("profile.follow_button")}
|
|
49
|
+
</ActionButton>
|
|
50
|
+
}
|
|
51
|
+
/>
|
|
52
|
+
|
|
53
|
+
<section className="space-y-4">
|
|
54
|
+
<SectionTitle>{t("profile.posts_header")}</SectionTitle>
|
|
55
|
+
{posts.length === 0 ? (
|
|
56
|
+
<EmptyState title="No posts yet" description={t("profile.no_posts_user")} />
|
|
57
|
+
) : (
|
|
58
|
+
<div className="space-y-4">
|
|
59
|
+
{posts.map((post) => (
|
|
60
|
+
<div key={post.id} className="rounded-card border border-[var(--color-border-default)] bg-[var(--color-surface-secondary)] p-4 shadow-sm">
|
|
61
|
+
<PostCard
|
|
62
|
+
post={post}
|
|
63
|
+
author={user}
|
|
64
|
+
onOpen={() => navigate(`/posts/${post.id}`)}
|
|
65
|
+
onAuthor={() => navigate(`/u/${user.id}`)}
|
|
66
|
+
onLike={() => state.toggleLike(post.id)}
|
|
67
|
+
onSave={() => state.toggleSave(post.id)}
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
</section>
|
|
74
|
+
</ScreenScaffold>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useEffect, useState, startTransition } from "react";
|
|
2
|
+
import { useNavigate, useSearchParams } from "react-router";
|
|
3
|
+
import { useI18n } from "../i18n";
|
|
4
|
+
import { ActionButton, EmptyState, ErrorState, ScreenScaffold, SkeletonList, TextField } from "../components/ui";
|
|
5
|
+
import { selectSearchResults, useAppStore } from "../state/store";
|
|
6
|
+
import { useSimulatedLoading, useUiScenario } from "../lib/utils";
|
|
7
|
+
|
|
8
|
+
export function SearchResultsScreen() {
|
|
9
|
+
const { t } = useI18n();
|
|
10
|
+
const navigate = useNavigate();
|
|
11
|
+
const scenario = useUiScenario();
|
|
12
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
13
|
+
const [query, setQuery] = useState(searchParams.get("query") ?? "");
|
|
14
|
+
const [activeTab, setActiveTab] = useState<"posts" | "people" | "tags">(
|
|
15
|
+
(searchParams.get("tab") as "posts" | "people" | "tags") ?? "posts",
|
|
16
|
+
);
|
|
17
|
+
const loading = useSimulatedLoading(`search-${query}-${activeTab}`, scenario);
|
|
18
|
+
const state = useAppStore();
|
|
19
|
+
const results = selectSearchResults(state, query, activeTab);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
setQuery(searchParams.get("query") ?? "");
|
|
23
|
+
setActiveTab(((searchParams.get("tab") as "posts" | "people" | "tags") ?? "posts"));
|
|
24
|
+
}, [searchParams]);
|
|
25
|
+
|
|
26
|
+
const visibleResults = scenario === "empty" ? [] : results;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<ScreenScaffold title="Search Results" subtitle={`Querying "${query}" across ${activeTab}.`}>
|
|
30
|
+
<TextField
|
|
31
|
+
label={t("search.placeholder")}
|
|
32
|
+
value={query}
|
|
33
|
+
onValueChange={setQuery}
|
|
34
|
+
placeholder={t("search.placeholder")}
|
|
35
|
+
trailingAction={
|
|
36
|
+
<ActionButton
|
|
37
|
+
variant="primary"
|
|
38
|
+
icon="search"
|
|
39
|
+
onClick={() => setSearchParams({ query, tab: activeTab })}
|
|
40
|
+
>
|
|
41
|
+
Search
|
|
42
|
+
</ActionButton>
|
|
43
|
+
}
|
|
44
|
+
/>
|
|
45
|
+
|
|
46
|
+
<div className="no-scrollbar flex gap-2 overflow-x-auto pb-1">
|
|
47
|
+
{[
|
|
48
|
+
{ value: "posts" as const, label: t("search.tab_posts") },
|
|
49
|
+
{ value: "people" as const, label: t("search.tab_people") },
|
|
50
|
+
{ value: "tags" as const, label: t("search.tab_tags") },
|
|
51
|
+
].map((tab) => (
|
|
52
|
+
<ActionButton
|
|
53
|
+
key={tab.value}
|
|
54
|
+
variant="chip"
|
|
55
|
+
selected={activeTab === tab.value}
|
|
56
|
+
onClick={() => {
|
|
57
|
+
startTransition(() => setActiveTab(tab.value));
|
|
58
|
+
setSearchParams({ query, tab: tab.value });
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
{tab.label}
|
|
62
|
+
</ActionButton>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{scenario === "error" ? (
|
|
67
|
+
<ErrorState title="Search failed" description="The search request is in an error state. Remove `?ui=error` to see results." />
|
|
68
|
+
) : loading ? (
|
|
69
|
+
<SkeletonList count={5} />
|
|
70
|
+
) : visibleResults.length === 0 ? (
|
|
71
|
+
<EmptyState title="No results" description={t("search.no_results")} />
|
|
72
|
+
) : (
|
|
73
|
+
<div className="space-y-3">
|
|
74
|
+
{visibleResults.map((result) => (
|
|
75
|
+
<button
|
|
76
|
+
key={`${result.kind}-${result.id}`}
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={() => {
|
|
79
|
+
if (result.kind === "people") {
|
|
80
|
+
navigate(`/u/${result.id}`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
navigate(`/posts/${result.id}`);
|
|
84
|
+
}}
|
|
85
|
+
className="w-full rounded-card border border-[var(--color-border-default)] bg-[var(--color-surface-secondary)] p-4 text-left shadow-sm transition hover:-translate-y-0.5"
|
|
86
|
+
>
|
|
87
|
+
<p className="font-semibold text-[var(--color-text-primary)]">{result.title}</p>
|
|
88
|
+
<p className="mt-1 text-sm text-[var(--color-text-secondary)]">{result.subtitle}</p>
|
|
89
|
+
<p className="mt-3 line-clamp-2 text-sm leading-6 text-[var(--color-text-secondary)]">{result.body}</p>
|
|
90
|
+
</button>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</ScreenScaffold>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useNavigate } from "react-router";
|
|
2
|
+
import { useI18n } from "../i18n";
|
|
3
|
+
import { ActionButton, ScreenScaffold, SectionTitle, SelectField, ToggleField } from "../components/ui";
|
|
4
|
+
import { useAppStore } from "../state/store";
|
|
5
|
+
|
|
6
|
+
export function SettingsScreen() {
|
|
7
|
+
const { t } = useI18n();
|
|
8
|
+
const navigate = useNavigate();
|
|
9
|
+
const state = useAppStore();
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<ScreenScaffold title="Settings" subtitle="Theme select, toggles, and a confirmation dialog for logout.">
|
|
13
|
+
<section className="space-y-4">
|
|
14
|
+
<SectionTitle>{t("settings.appearance")}</SectionTitle>
|
|
15
|
+
<SelectField
|
|
16
|
+
label={t("settings.theme")}
|
|
17
|
+
value={state.preferences.theme}
|
|
18
|
+
options={[
|
|
19
|
+
{ value: "system", label: t("settings.theme_system") },
|
|
20
|
+
{ value: "light", label: t("settings.theme_light") },
|
|
21
|
+
{ value: "dark", label: t("settings.theme_dark") },
|
|
22
|
+
]}
|
|
23
|
+
onValueChange={(value) => state.updatePreference("theme", value as "system" | "light" | "dark")}
|
|
24
|
+
/>
|
|
25
|
+
</section>
|
|
26
|
+
|
|
27
|
+
<section className="space-y-4">
|
|
28
|
+
<SectionTitle>{t("settings.notifications")}</SectionTitle>
|
|
29
|
+
<ToggleField
|
|
30
|
+
label={t("settings.push_notifications")}
|
|
31
|
+
checked={state.preferences.pushNotifications}
|
|
32
|
+
onChange={(value) => state.updatePreference("pushNotifications", value)}
|
|
33
|
+
/>
|
|
34
|
+
<ToggleField
|
|
35
|
+
label={t("settings.message_previews")}
|
|
36
|
+
checked={state.preferences.messagePreviews}
|
|
37
|
+
onChange={(value) => state.updatePreference("messagePreviews", value)}
|
|
38
|
+
/>
|
|
39
|
+
</section>
|
|
40
|
+
|
|
41
|
+
<section className="space-y-4">
|
|
42
|
+
<SectionTitle>{t("settings.language")}</SectionTitle>
|
|
43
|
+
<ToggleField
|
|
44
|
+
label={t("settings.auto_translate")}
|
|
45
|
+
checked={state.preferences.autoTranslate}
|
|
46
|
+
onChange={(value) => state.updatePreference("autoTranslate", value)}
|
|
47
|
+
/>
|
|
48
|
+
</section>
|
|
49
|
+
|
|
50
|
+
<section className="space-y-4">
|
|
51
|
+
<SectionTitle>{t("settings.account")}</SectionTitle>
|
|
52
|
+
<ActionButton variant="secondary" icon="edit" onClick={() => navigate("/profile/edit")}>
|
|
53
|
+
{t("settings.edit_profile")}
|
|
54
|
+
</ActionButton>
|
|
55
|
+
<ActionButton
|
|
56
|
+
variant="destructive"
|
|
57
|
+
onClick={() =>
|
|
58
|
+
state.openDialog({
|
|
59
|
+
title: t("settings.logout"),
|
|
60
|
+
message: t("settings.logout_confirm"),
|
|
61
|
+
actions: [
|
|
62
|
+
{ label: t("common.cancel"), variant: "secondary", onPress: () => undefined },
|
|
63
|
+
{
|
|
64
|
+
label: t("settings.logout"),
|
|
65
|
+
variant: "destructive",
|
|
66
|
+
onPress: () => state.logout(),
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
>
|
|
72
|
+
{t("settings.logout")}
|
|
73
|
+
</ActionButton>
|
|
74
|
+
</section>
|
|
75
|
+
</ScreenScaffold>
|
|
76
|
+
);
|
|
77
|
+
}
|