robodev 0.11.0 → 0.12.0

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.
Files changed (183) hide show
  1. package/package.json +1 -1
  2. package/src/collect-files.test.ts +19 -0
  3. package/src/collect-files.ts +1 -0
  4. package/src/config.test.ts +26 -0
  5. package/src/config.ts +34 -5
  6. package/src/emit-starter.test.ts +54 -0
  7. package/src/emit-starter.ts +47 -13
  8. package/src/index.ts +37 -4
  9. package/templates/auth-chat/package.json +1 -1
  10. package/templates/auth-chat/src/main.tsx +1 -1
  11. package/templates/backend/package.json +1 -1
  12. package/templates/catalog.json +1 -1
  13. package/templates/empty/package.json +1 -1
  14. package/templates/space/.config/local.spa.template.yml +27 -0
  15. package/templates/space/.oxlint-base.json +29 -0
  16. package/templates/space/.oxlintrc.json +78 -0
  17. package/templates/space/.rulesync/rules/frontend-api-boundary.md +36 -0
  18. package/templates/space/.rulesync/rules/frontend-component-structure.md +38 -0
  19. package/templates/space/.rulesync/rules/frontend-forms.md +32 -0
  20. package/templates/space/.rulesync/rules/frontend-notifications.md +24 -0
  21. package/templates/space/.rulesync/rules/frontend-povio-components.md +42 -0
  22. package/templates/space/.rulesync/rules/frontend-povio-ui.md +45 -0
  23. package/templates/space/.rulesync/rules/frontend-query-autocomplete.md +29 -0
  24. package/templates/space/.rulesync/rules/frontend-tables-and-lists.md +34 -0
  25. package/templates/space/.rulesync/rules/frontend-translations.md +26 -0
  26. package/templates/space/.rulesync/rules/project-overview.md +32 -0
  27. package/templates/space/.rulesync/rules/robodev-api.md +30 -0
  28. package/templates/space/.rulesync/rules/robodev-auth.md +40 -0
  29. package/templates/space/.rulesync/rules/robodev-database.md +22 -0
  30. package/templates/space/.rulesync/rules/role-based-app-structure.md +36 -0
  31. package/templates/space/.rulesync/skills/media-feature/SKILL.md +21 -0
  32. package/templates/space/.rulesync/skills/povio-ui-styling/SKILL.md +208 -0
  33. package/templates/space/.rulesync/skills/povio-ui-styling/agents/openai.yaml +4 -0
  34. package/templates/space/.rulesync/skills/robodev-api-route/SKILL.md +13 -0
  35. package/templates/space/.rulesync/skills/robodev-table/SKILL.md +12 -0
  36. package/templates/space/README.md +26 -7
  37. package/templates/space/api/_lib.ts +359 -0
  38. package/templates/space/api/aliens/labels.ts +19 -0
  39. package/templates/space/api/files/presigned-url.ts +48 -0
  40. package/templates/space/api/files/upload.ts +29 -0
  41. package/templates/space/api/planets/[id]/like.ts +36 -0
  42. package/templates/space/api/planets/[id].ts +56 -13
  43. package/templates/space/api/planets/paginate.ts +36 -0
  44. package/templates/space/api/planets.ts +52 -44
  45. package/templates/space/apps/fe/index.html +24 -0
  46. package/templates/space/apps/fe/openapi-codegen.config.ts +37 -0
  47. package/templates/space/apps/fe/package.json +82 -0
  48. package/templates/space/apps/fe/public/apple-touch-icon.png +0 -0
  49. package/templates/space/apps/fe/public/favicon-96x96.png +0 -0
  50. package/templates/space/apps/fe/public/favicon.ico +0 -0
  51. package/templates/space/apps/fe/public/favicon.svg +3 -0
  52. package/templates/space/apps/fe/public/site.webmanifest +21 -0
  53. package/templates/space/apps/fe/public/web-app-manifest-192x192.png +0 -0
  54. package/templates/space/apps/fe/public/web-app-manifest-512x512.png +0 -0
  55. package/templates/space/apps/fe/src/assets/fonts/GeneralSans-Bold.otf +0 -0
  56. package/templates/space/apps/fe/src/assets/fonts/GeneralSans-Medium.otf +0 -0
  57. package/templates/space/apps/fe/src/assets/fonts/GeneralSans-Regular.otf +0 -0
  58. package/templates/space/apps/fe/src/assets/fonts/GeneralSans-Semibold.otf +0 -0
  59. package/templates/space/apps/fe/src/assets/locales/en/translation.json +301 -0
  60. package/templates/space/apps/fe/src/assets/locales/sl/translation.json +301 -0
  61. package/templates/space/apps/fe/src/clients/app-rest-client.ts +15 -0
  62. package/templates/space/apps/fe/src/clients/auth-token-store.ts +101 -0
  63. package/templates/space/apps/fe/src/clients/rest/app-error-handler.ts +31 -0
  64. package/templates/space/apps/fe/src/clients/rest/interceptors/authorization-header.interceptor.ts +15 -0
  65. package/templates/space/apps/fe/src/clients/rest/interceptors/refresh-token.interceptor.ts +37 -0
  66. package/templates/space/apps/fe/src/clients/rest/interceptors/response.interceptor.ts +17 -0
  67. package/templates/space/apps/fe/src/components/404.tsx +18 -0
  68. package/templates/space/apps/fe/src/components/features/auth/AuthBrandPanel.tsx +60 -0
  69. package/templates/space/apps/fe/src/components/features/auth/AuthLayout.tsx +23 -0
  70. package/templates/space/apps/fe/src/components/features/auth/LoginPage.tsx +111 -0
  71. package/templates/space/apps/fe/src/components/features/auth/RegisterPage.tsx +143 -0
  72. package/templates/space/apps/fe/src/components/features/home/HomePage.tsx +21 -0
  73. package/templates/space/apps/fe/src/components/features/planets/details/PlanetDetailsPage.tsx +139 -0
  74. package/templates/space/apps/fe/src/components/features/planets/details/PlanetEditPage.tsx +266 -0
  75. package/templates/space/apps/fe/src/components/features/planets/feed/PlanetFeedCard.tsx +131 -0
  76. package/templates/space/apps/fe/src/components/features/planets/feed/PlanetsFeedPage.tsx +119 -0
  77. package/templates/space/apps/fe/src/components/features/planets/list/PlanetCard.tsx +89 -0
  78. package/templates/space/apps/fe/src/components/features/planets/list/PlanetCreateModal.tsx +179 -0
  79. package/templates/space/apps/fe/src/components/features/planets/list/PlanetsFilters.css +3 -0
  80. package/templates/space/apps/fe/src/components/features/planets/list/PlanetsFilters.tsx +73 -0
  81. package/templates/space/apps/fe/src/components/features/planets/list/PlanetsGridPage.tsx +51 -0
  82. package/templates/space/apps/fe/src/components/features/planets/list/PlanetsInfiniteTablePage.tsx +79 -0
  83. package/templates/space/apps/fe/src/components/features/planets/list/PlanetsPage.tsx +65 -0
  84. package/templates/space/apps/fe/src/components/features/planets/list/PlanetsTablePage.tsx +72 -0
  85. package/templates/space/apps/fe/src/components/features/planets/list/PlanetsTableSummary.tsx +29 -0
  86. package/templates/space/apps/fe/src/components/features/planets/list/PlanetsViewTabs.tsx +79 -0
  87. package/templates/space/apps/fe/src/components/features/planets/list/table/PlanetsTable.tsx +30 -0
  88. package/templates/space/apps/fe/src/components/features/planets/list/table/PlanetsTableActions.tsx +64 -0
  89. package/templates/space/apps/fe/src/components/features/planets/list/table/PlanetsTableInfinite.tsx +36 -0
  90. package/templates/space/apps/fe/src/components/features/planets/list/table/planetsTableColumns.tsx +72 -0
  91. package/templates/space/apps/fe/src/components/features/planets/list/table/usePlanetTableFilters.tsx +102 -0
  92. package/templates/space/apps/fe/src/components/features/profile/ProfilePage.tsx +96 -0
  93. package/templates/space/apps/fe/src/components/googleAnalytics/GoogleAnalytics.tsx +35 -0
  94. package/templates/space/apps/fe/src/components/layout/AppLayout.tsx +19 -0
  95. package/templates/space/apps/fe/src/components/layout/app-header/AppHeader.tsx +93 -0
  96. package/templates/space/apps/fe/src/components/layout/app-header/MobileNavigation.tsx +53 -0
  97. package/templates/space/apps/fe/src/components/layout/app-header/NavLink.tsx +29 -0
  98. package/templates/space/apps/fe/src/components/shared/branding/BrandLogo.tsx +13 -0
  99. package/templates/space/apps/fe/src/components/shared/error/ErrorFallback.tsx +43 -0
  100. package/templates/space/apps/fe/src/components/shared/error/ErrorText.tsx +20 -0
  101. package/templates/space/apps/fe/src/components/shared/error/NotFound.tsx +36 -0
  102. package/templates/space/apps/fe/src/components/shared/forms/RequiredLabel.tsx +21 -0
  103. package/templates/space/apps/fe/src/components/shared/forms/RowInputWrapper.tsx +50 -0
  104. package/templates/space/apps/fe/src/components/shared/head/AppHead.tsx +32 -0
  105. package/templates/space/apps/fe/src/components/shared/head/DefaultAppHead.tsx +34 -0
  106. package/templates/space/apps/fe/src/components/shared/layout/LoadingState.tsx +9 -0
  107. package/templates/space/apps/fe/src/components/shared/layout/ThinPageWrapper.tsx +5 -0
  108. package/templates/space/apps/fe/src/components/shared/page/PageHeader.tsx +69 -0
  109. package/templates/space/apps/fe/src/components/shared/ui/Card.tsx +60 -0
  110. package/templates/space/apps/fe/src/components/shared/ui/GoogleLoginButton.tsx +19 -0
  111. package/templates/space/apps/fe/src/components/shared/ui/RequiredLabel.tsx +22 -0
  112. package/templates/space/apps/fe/src/components/shared/ui/TableActions.tsx +22 -0
  113. package/templates/space/apps/fe/src/config/app.config.ts +35 -0
  114. package/templates/space/apps/fe/src/config/i18n.ts +43 -0
  115. package/templates/space/apps/fe/src/config/inits/a11y.ts +14 -0
  116. package/templates/space/apps/fe/src/config/inits/logger.ts +7 -0
  117. package/templates/space/apps/fe/src/config/inits/sentry.ts +21 -0
  118. package/templates/space/apps/fe/src/config/jwt.config.ts +2 -0
  119. package/templates/space/apps/fe/src/config/query.config.ts +20 -0
  120. package/templates/space/apps/fe/src/hooks/useAuth.ts +5 -0
  121. package/templates/space/apps/fe/src/main.tsx +52 -0
  122. package/templates/space/apps/fe/src/pages/(guest)/login.tsx +23 -0
  123. package/templates/space/apps/fe/src/pages/(guest)/register.tsx +23 -0
  124. package/templates/space/apps/fe/src/pages/(guest)/route.tsx +18 -0
  125. package/templates/space/apps/fe/src/pages/(private)/index.tsx +23 -0
  126. package/templates/space/apps/fe/src/pages/(private)/planets/$id/edit.tsx +53 -0
  127. package/templates/space/apps/fe/src/pages/(private)/planets/$id/index.tsx +41 -0
  128. package/templates/space/apps/fe/src/pages/(private)/planets/index.tsx +24 -0
  129. package/templates/space/apps/fe/src/pages/(private)/planets-feed.tsx +23 -0
  130. package/templates/space/apps/fe/src/pages/(private)/profile.tsx +23 -0
  131. package/templates/space/apps/fe/src/pages/(private)/route.tsx +18 -0
  132. package/templates/space/apps/fe/src/pages/(public)/auth.tsx +37 -0
  133. package/templates/space/apps/fe/src/pages/(public)/route.tsx +9 -0
  134. package/templates/space/apps/fe/src/pages/__root.tsx +164 -0
  135. package/templates/space/apps/fe/src/providers/AppErrorBoundary.tsx +10 -0
  136. package/templates/space/apps/fe/src/providers/OpenApiRuntimeProvider.tsx +31 -0
  137. package/templates/space/apps/fe/src/providers/index.tsx +44 -0
  138. package/templates/space/apps/fe/src/providers/jwt.provider.tsx +95 -0
  139. package/templates/space/apps/fe/src/routeTree.gen.ts +309 -0
  140. package/templates/space/apps/fe/src/styles/base.css +103 -0
  141. package/templates/space/apps/fe/src/styles/fonts/fonts.tsx +10 -0
  142. package/templates/space/apps/fe/src/styles/fonts/general-sans.css +31 -0
  143. package/templates/space/apps/fe/src/styles/globals.css +28 -0
  144. package/templates/space/apps/fe/src/styles/overrides/defaults/button.override.ts +536 -0
  145. package/templates/space/apps/fe/src/styles/overrides/defaults/checkbox.override.ts +71 -0
  146. package/templates/space/apps/fe/src/styles/overrides/defaults/input.override.ts +252 -0
  147. package/templates/space/apps/fe/src/styles/overrides/defaults/label.override.ts +91 -0
  148. package/templates/space/apps/fe/src/styles/overrides/defaults/modal.override.ts +57 -0
  149. package/templates/space/apps/fe/src/styles/overrides/defaults/radio.override.ts +46 -0
  150. package/templates/space/apps/fe/src/styles/overrides/defaults/table.override.ts +104 -0
  151. package/templates/space/apps/fe/src/styles/overrides/defaults/tag.override.ts +66 -0
  152. package/templates/space/apps/fe/src/styles/overrides/defaults/typography.override.ts +115 -0
  153. package/templates/space/apps/fe/src/styles/overrides/outline.clsx.ts +10 -0
  154. package/templates/space/apps/fe/src/styles/overrides/uiOverrides.override.ts +60 -0
  155. package/templates/space/apps/fe/src/styles/theme.css +2177 -0
  156. package/templates/space/apps/fe/src/types/i18next.d.ts +12 -0
  157. package/templates/space/apps/fe/src/types/table.d.ts +17 -0
  158. package/templates/space/apps/fe/src/types/ui.d.ts +26 -0
  159. package/templates/space/apps/fe/src/types/vite-env.d.ts +20 -0
  160. package/templates/space/apps/fe/src/utils/date.utils.ts +17 -0
  161. package/templates/space/apps/fe/src/utils/image-fallback.ts +9 -0
  162. package/templates/space/apps/fe/src/utils/media-upload.ts +63 -0
  163. package/templates/space/apps/fe/src/utils/number.utils.ts +12 -0
  164. package/templates/space/apps/fe/src/utils/string.utils.ts +5 -0
  165. package/templates/space/apps/fe/src/vite-env.d.ts +1 -0
  166. package/templates/space/apps/fe/tsconfig.app.json +32 -0
  167. package/templates/space/apps/fe/tsconfig.json +9 -0
  168. package/templates/space/apps/fe/tsconfig.node.json +31 -0
  169. package/templates/space/apps/fe/vite.config.ts +94 -0
  170. package/templates/space/database.ts +33 -13
  171. package/templates/space/openapi.json +1819 -0
  172. package/templates/space/oxfmt.config.js +23 -0
  173. package/templates/space/package.json +6 -13
  174. package/templates/space/rulesync.jsonc +18 -0
  175. package/templates/space/tsconfig.json +2 -4
  176. package/templates/space/api/health.ts +0 -7
  177. package/templates/space/api/launch.ts +0 -29
  178. package/templates/space/api/me.ts +0 -14
  179. package/templates/space/api/motto.ts +0 -9
  180. package/templates/space/api/rockets.ts +0 -43
  181. package/templates/space/index.html +0 -138
  182. package/templates/space/src/main.tsx +0 -229
  183. package/templates/space/vite.config.ts +0 -7
@@ -0,0 +1,131 @@
1
+ import { Button, Link, Typography } from "@povio/ui";
2
+ import { Heart, Satellite, UserRound } from "lucide-react";
3
+ import { useTranslation } from "react-i18next";
4
+
5
+ import type { PlanetModels } from "@/openapi/planet/planet.models";
6
+ import { DateUtils } from "@/utils/date.utils";
7
+ import { getFallbackImageUrl } from "@/utils/image-fallback";
8
+
9
+ type PlanetFeedItem = PlanetModels.PlanetsGetResponseDto;
10
+
11
+ interface PlanetFeedCardProps {
12
+ planet: PlanetFeedItem;
13
+ isLikePending: boolean;
14
+ onToggleLike: (planet: PlanetFeedItem) => void;
15
+ }
16
+
17
+ export function PlanetFeedCard({ planet, isLikePending, onToggleLike }: PlanetFeedCardProps) {
18
+ const { t } = useTranslation();
19
+ const imageSrc = planet.image?.url ?? getFallbackImageUrl({ width: 960, height: 540 });
20
+ const discoveryDate = planet.discoveryDate ? DateUtils.formatDate(planet.discoveryDate) : null;
21
+ const alienName = planet.alienName ?? t(($) => $.planetsFeed.card.noAlien);
22
+ const isLiked = planet.likedByMe;
23
+ const likesCount = typeof planet.likesCount === "number" ? planet.likesCount : 0;
24
+
25
+ return (
26
+ <article className="overflow-hidden rounded-m border border-elevation-outline-default-1 bg-elevation-fill-default-1 shadow-sm">
27
+ <Link
28
+ to="/planets/$id"
29
+ params={{ id: planet.id }}
30
+ className="block no-underline! outline-none focus-visible:ring-2 focus-visible:ring-interactive-outline-primary-idle"
31
+ >
32
+ <div className="aspect-video w-full bg-elevation-fill-default-2">
33
+ <img
34
+ src={imageSrc}
35
+ alt=""
36
+ className="h-full w-full object-cover"
37
+ />
38
+ </div>
39
+ </Link>
40
+
41
+ <div className="flex flex-col gap-4 p-4 sm:p-5">
42
+ <div className="flex items-start justify-between gap-4">
43
+ <div className="min-w-0">
44
+ <Typography
45
+ as="h2"
46
+ size="title-4"
47
+ variant="prominent-1"
48
+ className="text-text-default-1"
49
+ >
50
+ {planet.name}
51
+ </Typography>
52
+ {discoveryDate ? (
53
+ <Typography
54
+ size="label-3"
55
+ className="mt-1 text-text-default-2"
56
+ >
57
+ {t(($) => $.planetsFeed.card.discovered, { date: discoveryDate })}
58
+ </Typography>
59
+ ) : null}
60
+ </div>
61
+
62
+ <Button
63
+ variant={isLiked ? "contained" : "outlined"}
64
+ color={isLiked ? "primary" : "secondary"}
65
+ size="s"
66
+ icon={Heart}
67
+ isLoading={isLikePending}
68
+ onPress={() => onToggleLike(planet)}
69
+ >
70
+ {isLiked
71
+ ? t(($) => $.planetsFeed.card.liked, { count: likesCount })
72
+ : t(($) => $.planetsFeed.card.like, { count: likesCount })}
73
+ </Button>
74
+ </div>
75
+
76
+ {planet.description ? (
77
+ <Typography
78
+ size="body-3"
79
+ className="text-text-default-2"
80
+ >
81
+ {planet.description}
82
+ </Typography>
83
+ ) : null}
84
+
85
+ <div className="grid gap-3 sm:grid-cols-2">
86
+ <div className="flex min-w-0 items-center gap-3 rounded-s bg-elevation-fill-default-2 px-3 py-2">
87
+ <Satellite
88
+ className="size-5 shrink-0 text-text-default-2"
89
+ aria-hidden
90
+ />
91
+ <div className="min-w-0">
92
+ <Typography
93
+ size="label-3"
94
+ className="text-text-default-2"
95
+ >
96
+ {t(($) => $.planetsFeed.card.alienLabel)}
97
+ </Typography>
98
+ <Typography
99
+ size="body-3"
100
+ className="truncate text-text-default-1"
101
+ >
102
+ {alienName}
103
+ </Typography>
104
+ </div>
105
+ </div>
106
+
107
+ <div className="flex min-w-0 items-center gap-3 rounded-s bg-elevation-fill-default-2 px-3 py-2">
108
+ <UserRound
109
+ className="size-5 shrink-0 text-text-default-2"
110
+ aria-hidden
111
+ />
112
+ <div className="min-w-0">
113
+ <Typography
114
+ size="label-3"
115
+ className="text-text-default-2"
116
+ >
117
+ {t(($) => $.planetsFeed.card.creatorLabel)}
118
+ </Typography>
119
+ <Typography
120
+ size="body-3"
121
+ className="truncate text-text-default-1"
122
+ >
123
+ {planet.creatorName ?? t(($) => $.planets.card.unknownCreator)}
124
+ </Typography>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ </article>
130
+ );
131
+ }
@@ -0,0 +1,119 @@
1
+ import { useQueryClient } from "@tanstack/react-query";
2
+ import { Typography } from "@povio/ui";
3
+ import { useCallback, useEffect, useRef } from "react";
4
+ import { useTranslation } from "react-i18next";
5
+
6
+ import { LoadingState } from "@/components/shared/layout/LoadingState";
7
+ import { PageHeader } from "@/components/shared/page/PageHeader";
8
+ import type { PlanetModels } from "@/openapi/planet/planet.models";
9
+ import { PlanetQueries } from "@/openapi/planet/planet.queries";
10
+
11
+ import { PlanetFeedCard } from "./PlanetFeedCard";
12
+
13
+ const PAGE_SIZE = 8;
14
+
15
+ type PlanetFeedItem = PlanetModels.PlanetsGetResponseDto;
16
+
17
+ export function PlanetsFeedPage() {
18
+ const { t } = useTranslation();
19
+ const queryClient = useQueryClient();
20
+ const loaderRef = useRef<HTMLDivElement | null>(null);
21
+
22
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = PlanetQueries.usePaginateInfinite({
23
+ cursor: undefined,
24
+ limit: PAGE_SIZE,
25
+ order: "-discoveryDate",
26
+ });
27
+
28
+ const likeMutation = PlanetQueries.useLike({
29
+ onSuccess: async () => {
30
+ await queryClient.invalidateQueries({ queryKey: PlanetQueries.keys.all });
31
+ },
32
+ });
33
+
34
+ const unlikeMutation = PlanetQueries.useUnlike({
35
+ onSuccess: async () => {
36
+ await queryClient.invalidateQueries({ queryKey: PlanetQueries.keys.all });
37
+ },
38
+ });
39
+
40
+ const planets = data?.pages.flatMap((page) => page.items ?? []) ?? [];
41
+
42
+ useEffect(() => {
43
+ const loader = loaderRef.current;
44
+ if (!loader || !hasNextPage) {
45
+ return;
46
+ }
47
+
48
+ const observer = new IntersectionObserver(
49
+ ([entry]) => {
50
+ if (entry?.isIntersecting && !isFetchingNextPage) {
51
+ void fetchNextPage();
52
+ }
53
+ },
54
+ { rootMargin: "360px 0px" },
55
+ );
56
+
57
+ observer.observe(loader);
58
+ return () => observer.disconnect();
59
+ }, [fetchNextPage, hasNextPage, isFetchingNextPage]);
60
+
61
+ const toggleLike = useCallback(
62
+ (planet: PlanetFeedItem) => {
63
+ if (planet.likedByMe) {
64
+ unlikeMutation.mutate({ id: planet.id });
65
+ } else {
66
+ likeMutation.mutate({ id: planet.id });
67
+ }
68
+ },
69
+ [likeMutation.mutate, unlikeMutation.mutate],
70
+ );
71
+
72
+ return (
73
+ <div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
74
+ <PageHeader title={t(($) => $.planetsFeed.page.title)} />
75
+
76
+ {isLoading ? (
77
+ <LoadingState />
78
+ ) : (
79
+ <div className="flex flex-col gap-5">
80
+ {planets.map((planet) => (
81
+ <PlanetFeedCard
82
+ key={planet.id}
83
+ planet={planet}
84
+ isLikePending={
85
+ (likeMutation.isPending && likeMutation.variables?.id === planet.id) ||
86
+ (unlikeMutation.isPending && unlikeMutation.variables?.id === planet.id)
87
+ }
88
+ onToggleLike={toggleLike}
89
+ />
90
+ ))}
91
+ </div>
92
+ )}
93
+
94
+ {!isLoading && planets.length === 0 && (
95
+ <Typography
96
+ size="body-3"
97
+ className="text-text-default-2"
98
+ >
99
+ {t(($) => $.planetsFeed.page.empty)}
100
+ </Typography>
101
+ )}
102
+
103
+ <div
104
+ ref={loaderRef}
105
+ className="flex min-h-16 items-center justify-center"
106
+ >
107
+ {isFetchingNextPage && <LoadingState />}
108
+ {!hasNextPage && planets.length > 0 && (
109
+ <Typography
110
+ size="label-3"
111
+ className="text-text-default-2"
112
+ >
113
+ {t(($) => $.planetsFeed.page.end)}
114
+ </Typography>
115
+ )}
116
+ </div>
117
+ </div>
118
+ );
119
+ }
@@ -0,0 +1,89 @@
1
+ import { IconButton, Link, Typography } from "@povio/ui";
2
+ import { Pencil } from "lucide-react";
3
+ import { useTranslation } from "react-i18next";
4
+
5
+ import { useAuth } from "@/hooks/useAuth";
6
+ import type { PlanetModels } from "@/openapi/planet/planet.models";
7
+ import { DateUtils } from "@/utils/date.utils";
8
+ import { getFallbackImageUrl } from "@/utils/image-fallback";
9
+
10
+ interface PlanetCardProps {
11
+ planet: PlanetModels.PlanetsGetResponseDto;
12
+ }
13
+
14
+ export function PlanetCard({ planet }: PlanetCardProps) {
15
+ const { t } = useTranslation();
16
+ const { user } = useAuth();
17
+
18
+ const imageSrc = planet.image?.url ?? getFallbackImageUrl({ width: 400, height: 300 });
19
+ const createdDate = DateUtils.formatDate(new Date(planet.createdAt));
20
+ const creatorName = planet.creatorName ?? t(($) => $.planets.card.unknownCreator);
21
+ // Match the details/edit pages: show owner-only edit affordances, but keep backend authorization authoritative.
22
+ const canEdit = user?.id === planet.userId;
23
+
24
+ return (
25
+ <div className="relative overflow-hidden rounded-m border border-elevation-outline-default-1 bg-elevation-fill-default-1 shadow-sm transition-all hover:border-elevation-outline-default-2 hover:bg-elevation-fill-default-2">
26
+ {canEdit && (
27
+ <div className="absolute right-2 top-2 z-10">
28
+ <IconButton
29
+ size="xs"
30
+ variant="subtle"
31
+ label={t(($) => $.planets.actions.edit)}
32
+ icon={Pencil}
33
+ color="secondary"
34
+ link={{ to: "/planets/$id/edit", params: { id: planet.id } }}
35
+ />
36
+ </div>
37
+ )}
38
+
39
+ <Link
40
+ to="/planets/$id"
41
+ params={{ id: planet.id }}
42
+ className="flex flex-col no-underline! outline-none focus-visible:ring-2 focus-visible:ring-interactive-outline-primary-idle"
43
+ >
44
+ <div className="aspect-video w-full bg-elevation-fill-default-2">
45
+ <img
46
+ src={imageSrc}
47
+ alt=""
48
+ className="h-full w-full object-cover"
49
+ />
50
+ </div>
51
+
52
+ <div className="flex flex-col gap-2 p-4">
53
+ <Typography
54
+ size="title-5"
55
+ variant="prominent-1"
56
+ as="h3"
57
+ className="min-w-0 flex-1 text-text-default-1"
58
+ >
59
+ {planet.name}
60
+ </Typography>
61
+ {planet.description && (
62
+ <Typography
63
+ size="body-3"
64
+ className="line-clamp-2 w-full text-text-default-2"
65
+ >
66
+ {planet.description}
67
+ </Typography>
68
+ )}
69
+
70
+ <Typography
71
+ size="label-3"
72
+ className="pt-2 text-text-default-2"
73
+ >
74
+ {t(($) => $.planets.card.createdBy, { name: creatorName, date: createdDate })}
75
+ </Typography>
76
+
77
+ {planet.alienName && (
78
+ <Typography
79
+ size="label-3"
80
+ className="text-text-default-2"
81
+ >
82
+ {t(($) => $.planets.card.alien, { name: planet.alienName })}
83
+ </Typography>
84
+ )}
85
+ </div>
86
+ </Link>
87
+ </div>
88
+ );
89
+ }
@@ -0,0 +1,179 @@
1
+ import {
2
+ Button,
3
+ DatePicker,
4
+ FileUpload,
5
+ Modal,
6
+ QueryAutocomplete,
7
+ TextArea,
8
+ TextInput,
9
+ Typography,
10
+ useForm,
11
+ useToast,
12
+ } from "@povio/ui/tanstack";
13
+ import { useTranslation } from "react-i18next";
14
+
15
+ import { RowInputWrapper } from "@/components/shared/forms/RowInputWrapper";
16
+ import { AlienQueries } from "@/openapi/alien/alien.queries";
17
+ import { MediaModels } from "@/openapi/media/media.models";
18
+ import { PlanetModels } from "@/openapi/planet/planet.models";
19
+ import { PlanetQueries } from "@/openapi/planet/planet.queries";
20
+ import { useMediaUploadHandler } from "@/utils/media-upload";
21
+
22
+ interface PlanetCreateModalProps {
23
+ isOpen: boolean;
24
+ onClose: () => void;
25
+ }
26
+
27
+ const useAlienLabelsQuery = AlienQueries.useGetLabels as never;
28
+
29
+ export function PlanetCreateModal({ isOpen, onClose }: PlanetCreateModalProps) {
30
+ const { t } = useTranslation();
31
+ const createPlanet = PlanetQueries.useCreate();
32
+ const { successToast, errorToast } = useToast();
33
+ // Generated schemas drive form typing and validation; do not import backend types in frontend code.
34
+ const form = useForm({
35
+ zodSchema: PlanetModels.PlanetsCreateRequestDtoSchema,
36
+ defaultValues: {
37
+ name: "",
38
+ alienId: undefined,
39
+ discoveryDate: undefined,
40
+ description: "",
41
+ image: undefined,
42
+ },
43
+ });
44
+
45
+ const handleImageUpload = useMediaUploadHandler({
46
+ resourceName: MediaModels.MediaResourceName["planet-image"],
47
+ onUploaded: (media) => form.setFieldValue("image", { id: media.id }),
48
+ });
49
+
50
+ const handleImageChange = (file: File | null) => {
51
+ if (!file) {
52
+ form.setFieldValue("image", undefined);
53
+ }
54
+ };
55
+
56
+ const onSubmit = async (data: PlanetModels.PlanetsCreateRequestDto) => {
57
+ try {
58
+ await createPlanet.mutateAsync({ data });
59
+ successToast({ text: t(($) => $.planets.create.success) });
60
+ form.reset();
61
+ onClose();
62
+ } catch {
63
+ errorToast({ text: t(($) => $.planets.create.error) });
64
+ }
65
+ };
66
+
67
+ const handleClose = () => {
68
+ form.reset();
69
+ onClose();
70
+ };
71
+
72
+ return (
73
+ <Modal
74
+ isOpen={isOpen}
75
+ onClose={handleClose}
76
+ aside="right"
77
+ showCloseIcon
78
+ modalClassName="h-full min-w-0 overflow-y-auto"
79
+ >
80
+ <form
81
+ onSubmit={form.handleSubmit(onSubmit)}
82
+ className="flex w-full min-w-0 flex-col gap-4"
83
+ >
84
+ <Typography
85
+ size="title-5"
86
+ variant="prominent-1"
87
+ as="h2"
88
+ className="mb-2 text-text-default-1"
89
+ >
90
+ {t(($) => $.planets.create.title)}
91
+ </Typography>
92
+
93
+ <RowInputWrapper
94
+ label={t(($) => $.planets.create.name)}
95
+ isRequired
96
+ >
97
+ <TextInput
98
+ variant="filled"
99
+ size="extra-small"
100
+ field={{ form, name: "name" }}
101
+ label={t(($) => $.planets.create.name)}
102
+ placeholder={t(($) => $.planets.create.namePlaceholder)}
103
+ hideLabel
104
+ isRequired
105
+ />
106
+ </RowInputWrapper>
107
+
108
+ <RowInputWrapper label={t(($) => $.planets.create.alien)}>
109
+ <QueryAutocomplete
110
+ variant="filled"
111
+ size="extra-small"
112
+ field={{ form, name: "alienId" }}
113
+ query={useAlienLabelsQuery}
114
+ label={t(($) => $.planets.create.alien)}
115
+ placeholder={t(($) => $.planets.create.alienPlaceholder)}
116
+ hideLabel
117
+ />
118
+ </RowInputWrapper>
119
+
120
+ <RowInputWrapper label={t(($) => $.planets.create.discoveryDate)}>
121
+ <DatePicker
122
+ variant="filled"
123
+ size="extra-small"
124
+ field={{ form, name: "discoveryDate" }}
125
+ label={t(($) => $.planets.create.discoveryDate)}
126
+ placeholder={t(($) => $.planets.create.discoveryDatePlaceholder)}
127
+ hideLabel
128
+ />
129
+ </RowInputWrapper>
130
+
131
+ <RowInputWrapper label={t(($) => $.planets.create.description)}>
132
+ <TextArea
133
+ variant="filled"
134
+ size="extra-small"
135
+ field={{ form, name: "description" }}
136
+ label={t(($) => $.planets.create.description)}
137
+ placeholder={t(($) => $.planets.create.descriptionPlaceholder)}
138
+ hideLabel
139
+ />
140
+ </RowInputWrapper>
141
+
142
+ <FileUpload
143
+ label={t(($) => $.planets.create.image)}
144
+ emptyText={t(($) => $.planets.create.imageEmptyText)}
145
+ uploadText={t(($) => $.planets.create.imageUploadText)}
146
+ browseText={t(($) => $.planets.create.imageBrowseText)}
147
+ acceptedFileTypes={["image/jpeg", "image/png", "image/webp"]}
148
+ onChange={handleImageChange}
149
+ fileUpload={handleImageUpload}
150
+ />
151
+
152
+ <div className="mt-6 flex items-center justify-end gap-2 self-end">
153
+ <Button
154
+ type="button"
155
+ variant="outlined"
156
+ size="xs"
157
+ color="secondary"
158
+ width="hug"
159
+ onPress={handleClose}
160
+ >
161
+ {t(($) => $.planets.create.cancel)}
162
+ </Button>
163
+
164
+ <Button
165
+ type="submit"
166
+ variant="contained"
167
+ size="xs"
168
+ color="primary"
169
+ width="hug"
170
+ isDisabled={createPlanet.isPending}
171
+ isLoading={createPlanet.isPending}
172
+ >
173
+ {t(($) => $.planets.create.submit)}
174
+ </Button>
175
+ </div>
176
+ </form>
177
+ </Modal>
178
+ );
179
+ }
@@ -0,0 +1,3 @@
1
+ .planets-filters__control-without-label label {
2
+ display: none !important;
3
+ }
@@ -0,0 +1,73 @@
1
+ import { Button, DateRangePicker, QueryAutocomplete } from "@povio/ui/tanstack";
2
+ import type { FilterStore } from "@povio/ui/tanstack";
3
+ import { useTranslation } from "react-i18next";
4
+
5
+ import { AlienQueries } from "@/openapi/alien/alien.queries";
6
+ import type { PlanetModels } from "@/openapi/planet/planet.models";
7
+
8
+ import "./PlanetsFilters.css";
9
+
10
+ export type PlanetsFiltersValue = NonNullable<PlanetModels.PlanetPaginateFilterParam>;
11
+
12
+ interface PlanetsFiltersProps {
13
+ filters: FilterStore<PlanetsFiltersValue>;
14
+ }
15
+
16
+ function getDateRangeValue(value: PlanetsFiltersValue["discoveryDate"]) {
17
+ if (!value) {
18
+ return null;
19
+ }
20
+
21
+ return {
22
+ start: value.start ?? null,
23
+ end: value.end ?? null,
24
+ };
25
+ }
26
+
27
+ export function PlanetsFilters({ filters }: PlanetsFiltersProps) {
28
+ const { t } = useTranslation();
29
+ const handleClearAllFilters = () => filters.clearAllFilters();
30
+
31
+ return (
32
+ <div className="flex flex-wrap items-end gap-1 *:w-fit!">
33
+ {/* QueryAutocomplete works well with lightweight label endpoints instead of loading full related records. */}
34
+ <div className="planets-filters__control-without-label">
35
+ <QueryAutocomplete
36
+ value={filters.filterData.alienId ?? null}
37
+ onChange={(alienId) => filters.setFilterValue({ alienId: alienId ? String(alienId) : undefined })}
38
+ query={AlienQueries.useGetLabels}
39
+ label={t(($) => $.planets.filters.alien)}
40
+ placeholder={t(($) => $.planets.filters.alienPlaceholder)}
41
+ isClearable
42
+ as="filter"
43
+ size="extra-small"
44
+ variant="filled"
45
+ />
46
+ </div>
47
+
48
+ <div className="planets-filters__control-without-label">
49
+ <DateRangePicker
50
+ value={getDateRangeValue(filters.filterData.discoveryDate)}
51
+ onChange={(discoveryDate) => filters.setFilterValue({ discoveryDate: discoveryDate ?? undefined })}
52
+ label={t(($) => $.planets.filters.discoveryDate)}
53
+ placeholder={t(($) => $.planets.filters.discoveryDatePlaceholder)}
54
+ isClearable
55
+ // fullIso matches the backend z.iso.datetime schema and avoids local date parsing ambiguity.
56
+ fullIso
57
+ as="filter"
58
+ size="extra-small"
59
+ variant="filled"
60
+ />
61
+ </div>
62
+
63
+ <Button
64
+ variant="subtle"
65
+ color="secondary"
66
+ size="xs"
67
+ onPress={handleClearAllFilters}
68
+ >
69
+ {t(($) => $.planets.filters.clearAll)}
70
+ </Button>
71
+ </div>
72
+ );
73
+ }
@@ -0,0 +1,51 @@
1
+ import { Typography, useFilters } from "@povio/ui";
2
+ import { useTranslation } from "react-i18next";
3
+
4
+ import { LoadingState } from "@/components/shared/layout/LoadingState";
5
+ import { PlanetModels } from "@/openapi/planet/planet.models";
6
+ import { PlanetQueries } from "@/openapi/planet/planet.queries";
7
+
8
+ import { PlanetCard } from "./PlanetCard";
9
+ import { PlanetsFilters, type PlanetsFiltersValue } from "./PlanetsFilters";
10
+
11
+ export function PlanetsGridPage() {
12
+ const { t } = useTranslation();
13
+ const filters = useFilters<PlanetsFiltersValue>({}, "", PlanetModels.PlanetPaginateFilterParamSchema);
14
+
15
+ const { data, isLoading } = PlanetQueries.usePaginate({
16
+ page: 1,
17
+ cursor: undefined,
18
+ limit: 20,
19
+ filter: filters.filterData,
20
+ order: "-discoveryDate",
21
+ });
22
+ const planets = data?.items ?? [];
23
+
24
+ return (
25
+ <div className="flex flex-col gap-4">
26
+ <PlanetsFilters filters={filters} />
27
+
28
+ {isLoading ? (
29
+ <LoadingState />
30
+ ) : (
31
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
32
+ {planets.map((planet) => (
33
+ <PlanetCard
34
+ key={planet.id}
35
+ planet={planet}
36
+ />
37
+ ))}
38
+ </div>
39
+ )}
40
+
41
+ {!isLoading && planets.length === 0 && (
42
+ <Typography
43
+ size="body-3"
44
+ className="text-text-default-2"
45
+ >
46
+ {t(($) => $.planets.page.empty)}
47
+ </Typography>
48
+ )}
49
+ </div>
50
+ );
51
+ }