robodev 0.10.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 (197) 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/README.md +1 -1
  10. package/templates/auth-chat/api/health.ts +1 -1
  11. package/templates/auth-chat/api/me.ts +1 -1
  12. package/templates/auth-chat/api/messages.ts +1 -1
  13. package/templates/auth-chat/package.json +1 -1
  14. package/templates/auth-chat/src/main.tsx +5 -5
  15. package/templates/backend/README.md +1 -1
  16. package/templates/backend/api/health.ts +1 -1
  17. package/templates/backend/api/launch.ts +1 -1
  18. package/templates/backend/api/me.ts +1 -1
  19. package/templates/backend/api/motto.ts +1 -1
  20. package/templates/backend/api/planets/[id].ts +1 -1
  21. package/templates/backend/api/planets.ts +1 -1
  22. package/templates/backend/api/rockets.ts +1 -1
  23. package/templates/backend/package.json +1 -1
  24. package/templates/catalog.json +2 -2
  25. package/templates/empty/README.md +1 -1
  26. package/templates/empty/api/health.ts +1 -1
  27. package/templates/empty/package.json +1 -1
  28. package/templates/space/.config/local.spa.template.yml +27 -0
  29. package/templates/space/.oxlint-base.json +29 -0
  30. package/templates/space/.oxlintrc.json +78 -0
  31. package/templates/space/.rulesync/rules/frontend-api-boundary.md +36 -0
  32. package/templates/space/.rulesync/rules/frontend-component-structure.md +38 -0
  33. package/templates/space/.rulesync/rules/frontend-forms.md +32 -0
  34. package/templates/space/.rulesync/rules/frontend-notifications.md +24 -0
  35. package/templates/space/.rulesync/rules/frontend-povio-components.md +42 -0
  36. package/templates/space/.rulesync/rules/frontend-povio-ui.md +45 -0
  37. package/templates/space/.rulesync/rules/frontend-query-autocomplete.md +29 -0
  38. package/templates/space/.rulesync/rules/frontend-tables-and-lists.md +34 -0
  39. package/templates/space/.rulesync/rules/frontend-translations.md +26 -0
  40. package/templates/space/.rulesync/rules/project-overview.md +32 -0
  41. package/templates/space/.rulesync/rules/robodev-api.md +30 -0
  42. package/templates/space/.rulesync/rules/robodev-auth.md +40 -0
  43. package/templates/space/.rulesync/rules/robodev-database.md +22 -0
  44. package/templates/space/.rulesync/rules/role-based-app-structure.md +36 -0
  45. package/templates/space/.rulesync/skills/media-feature/SKILL.md +21 -0
  46. package/templates/space/.rulesync/skills/povio-ui-styling/SKILL.md +208 -0
  47. package/templates/space/.rulesync/skills/povio-ui-styling/agents/openai.yaml +4 -0
  48. package/templates/space/.rulesync/skills/robodev-api-route/SKILL.md +13 -0
  49. package/templates/space/.rulesync/skills/robodev-table/SKILL.md +12 -0
  50. package/templates/space/README.md +26 -7
  51. package/templates/space/api/_lib.ts +359 -0
  52. package/templates/space/api/aliens/labels.ts +19 -0
  53. package/templates/space/api/files/presigned-url.ts +48 -0
  54. package/templates/space/api/files/upload.ts +29 -0
  55. package/templates/space/api/planets/[id]/like.ts +36 -0
  56. package/templates/space/api/planets/[id].ts +56 -13
  57. package/templates/space/api/planets/paginate.ts +36 -0
  58. package/templates/space/api/planets.ts +52 -44
  59. package/templates/space/apps/fe/index.html +24 -0
  60. package/templates/space/apps/fe/openapi-codegen.config.ts +37 -0
  61. package/templates/space/apps/fe/package.json +82 -0
  62. package/templates/space/apps/fe/public/apple-touch-icon.png +0 -0
  63. package/templates/space/apps/fe/public/favicon-96x96.png +0 -0
  64. package/templates/space/apps/fe/public/favicon.ico +0 -0
  65. package/templates/space/apps/fe/public/favicon.svg +3 -0
  66. package/templates/space/apps/fe/public/site.webmanifest +21 -0
  67. package/templates/space/apps/fe/public/web-app-manifest-192x192.png +0 -0
  68. package/templates/space/apps/fe/public/web-app-manifest-512x512.png +0 -0
  69. package/templates/space/apps/fe/src/assets/fonts/GeneralSans-Bold.otf +0 -0
  70. package/templates/space/apps/fe/src/assets/fonts/GeneralSans-Medium.otf +0 -0
  71. package/templates/space/apps/fe/src/assets/fonts/GeneralSans-Regular.otf +0 -0
  72. package/templates/space/apps/fe/src/assets/fonts/GeneralSans-Semibold.otf +0 -0
  73. package/templates/space/apps/fe/src/assets/locales/en/translation.json +301 -0
  74. package/templates/space/apps/fe/src/assets/locales/sl/translation.json +301 -0
  75. package/templates/space/apps/fe/src/clients/app-rest-client.ts +15 -0
  76. package/templates/space/apps/fe/src/clients/auth-token-store.ts +101 -0
  77. package/templates/space/apps/fe/src/clients/rest/app-error-handler.ts +31 -0
  78. package/templates/space/apps/fe/src/clients/rest/interceptors/authorization-header.interceptor.ts +15 -0
  79. package/templates/space/apps/fe/src/clients/rest/interceptors/refresh-token.interceptor.ts +37 -0
  80. package/templates/space/apps/fe/src/clients/rest/interceptors/response.interceptor.ts +17 -0
  81. package/templates/space/apps/fe/src/components/404.tsx +18 -0
  82. package/templates/space/apps/fe/src/components/features/auth/AuthBrandPanel.tsx +60 -0
  83. package/templates/space/apps/fe/src/components/features/auth/AuthLayout.tsx +23 -0
  84. package/templates/space/apps/fe/src/components/features/auth/LoginPage.tsx +111 -0
  85. package/templates/space/apps/fe/src/components/features/auth/RegisterPage.tsx +143 -0
  86. package/templates/space/apps/fe/src/components/features/home/HomePage.tsx +21 -0
  87. package/templates/space/apps/fe/src/components/features/planets/details/PlanetDetailsPage.tsx +139 -0
  88. package/templates/space/apps/fe/src/components/features/planets/details/PlanetEditPage.tsx +266 -0
  89. package/templates/space/apps/fe/src/components/features/planets/feed/PlanetFeedCard.tsx +131 -0
  90. package/templates/space/apps/fe/src/components/features/planets/feed/PlanetsFeedPage.tsx +119 -0
  91. package/templates/space/apps/fe/src/components/features/planets/list/PlanetCard.tsx +89 -0
  92. package/templates/space/apps/fe/src/components/features/planets/list/PlanetCreateModal.tsx +179 -0
  93. package/templates/space/apps/fe/src/components/features/planets/list/PlanetsFilters.css +3 -0
  94. package/templates/space/apps/fe/src/components/features/planets/list/PlanetsFilters.tsx +73 -0
  95. package/templates/space/apps/fe/src/components/features/planets/list/PlanetsGridPage.tsx +51 -0
  96. package/templates/space/apps/fe/src/components/features/planets/list/PlanetsInfiniteTablePage.tsx +79 -0
  97. package/templates/space/apps/fe/src/components/features/planets/list/PlanetsPage.tsx +65 -0
  98. package/templates/space/apps/fe/src/components/features/planets/list/PlanetsTablePage.tsx +72 -0
  99. package/templates/space/apps/fe/src/components/features/planets/list/PlanetsTableSummary.tsx +29 -0
  100. package/templates/space/apps/fe/src/components/features/planets/list/PlanetsViewTabs.tsx +79 -0
  101. package/templates/space/apps/fe/src/components/features/planets/list/table/PlanetsTable.tsx +30 -0
  102. package/templates/space/apps/fe/src/components/features/planets/list/table/PlanetsTableActions.tsx +64 -0
  103. package/templates/space/apps/fe/src/components/features/planets/list/table/PlanetsTableInfinite.tsx +36 -0
  104. package/templates/space/apps/fe/src/components/features/planets/list/table/planetsTableColumns.tsx +72 -0
  105. package/templates/space/apps/fe/src/components/features/planets/list/table/usePlanetTableFilters.tsx +102 -0
  106. package/templates/space/apps/fe/src/components/features/profile/ProfilePage.tsx +96 -0
  107. package/templates/space/apps/fe/src/components/googleAnalytics/GoogleAnalytics.tsx +35 -0
  108. package/templates/space/apps/fe/src/components/layout/AppLayout.tsx +19 -0
  109. package/templates/space/apps/fe/src/components/layout/app-header/AppHeader.tsx +93 -0
  110. package/templates/space/apps/fe/src/components/layout/app-header/MobileNavigation.tsx +53 -0
  111. package/templates/space/apps/fe/src/components/layout/app-header/NavLink.tsx +29 -0
  112. package/templates/space/apps/fe/src/components/shared/branding/BrandLogo.tsx +13 -0
  113. package/templates/space/apps/fe/src/components/shared/error/ErrorFallback.tsx +43 -0
  114. package/templates/space/apps/fe/src/components/shared/error/ErrorText.tsx +20 -0
  115. package/templates/space/apps/fe/src/components/shared/error/NotFound.tsx +36 -0
  116. package/templates/space/apps/fe/src/components/shared/forms/RequiredLabel.tsx +21 -0
  117. package/templates/space/apps/fe/src/components/shared/forms/RowInputWrapper.tsx +50 -0
  118. package/templates/space/apps/fe/src/components/shared/head/AppHead.tsx +32 -0
  119. package/templates/space/apps/fe/src/components/shared/head/DefaultAppHead.tsx +34 -0
  120. package/templates/space/apps/fe/src/components/shared/layout/LoadingState.tsx +9 -0
  121. package/templates/space/apps/fe/src/components/shared/layout/ThinPageWrapper.tsx +5 -0
  122. package/templates/space/apps/fe/src/components/shared/page/PageHeader.tsx +69 -0
  123. package/templates/space/apps/fe/src/components/shared/ui/Card.tsx +60 -0
  124. package/templates/space/apps/fe/src/components/shared/ui/GoogleLoginButton.tsx +19 -0
  125. package/templates/space/apps/fe/src/components/shared/ui/RequiredLabel.tsx +22 -0
  126. package/templates/space/apps/fe/src/components/shared/ui/TableActions.tsx +22 -0
  127. package/templates/space/apps/fe/src/config/app.config.ts +35 -0
  128. package/templates/space/apps/fe/src/config/i18n.ts +43 -0
  129. package/templates/space/apps/fe/src/config/inits/a11y.ts +14 -0
  130. package/templates/space/apps/fe/src/config/inits/logger.ts +7 -0
  131. package/templates/space/apps/fe/src/config/inits/sentry.ts +21 -0
  132. package/templates/space/apps/fe/src/config/jwt.config.ts +2 -0
  133. package/templates/space/apps/fe/src/config/query.config.ts +20 -0
  134. package/templates/space/apps/fe/src/hooks/useAuth.ts +5 -0
  135. package/templates/space/apps/fe/src/main.tsx +52 -0
  136. package/templates/space/apps/fe/src/pages/(guest)/login.tsx +23 -0
  137. package/templates/space/apps/fe/src/pages/(guest)/register.tsx +23 -0
  138. package/templates/space/apps/fe/src/pages/(guest)/route.tsx +18 -0
  139. package/templates/space/apps/fe/src/pages/(private)/index.tsx +23 -0
  140. package/templates/space/apps/fe/src/pages/(private)/planets/$id/edit.tsx +53 -0
  141. package/templates/space/apps/fe/src/pages/(private)/planets/$id/index.tsx +41 -0
  142. package/templates/space/apps/fe/src/pages/(private)/planets/index.tsx +24 -0
  143. package/templates/space/apps/fe/src/pages/(private)/planets-feed.tsx +23 -0
  144. package/templates/space/apps/fe/src/pages/(private)/profile.tsx +23 -0
  145. package/templates/space/apps/fe/src/pages/(private)/route.tsx +18 -0
  146. package/templates/space/apps/fe/src/pages/(public)/auth.tsx +37 -0
  147. package/templates/space/apps/fe/src/pages/(public)/route.tsx +9 -0
  148. package/templates/space/apps/fe/src/pages/__root.tsx +164 -0
  149. package/templates/space/apps/fe/src/providers/AppErrorBoundary.tsx +10 -0
  150. package/templates/space/apps/fe/src/providers/OpenApiRuntimeProvider.tsx +31 -0
  151. package/templates/space/apps/fe/src/providers/index.tsx +44 -0
  152. package/templates/space/apps/fe/src/providers/jwt.provider.tsx +95 -0
  153. package/templates/space/apps/fe/src/routeTree.gen.ts +309 -0
  154. package/templates/space/apps/fe/src/styles/base.css +103 -0
  155. package/templates/space/apps/fe/src/styles/fonts/fonts.tsx +10 -0
  156. package/templates/space/apps/fe/src/styles/fonts/general-sans.css +31 -0
  157. package/templates/space/apps/fe/src/styles/globals.css +28 -0
  158. package/templates/space/apps/fe/src/styles/overrides/defaults/button.override.ts +536 -0
  159. package/templates/space/apps/fe/src/styles/overrides/defaults/checkbox.override.ts +71 -0
  160. package/templates/space/apps/fe/src/styles/overrides/defaults/input.override.ts +252 -0
  161. package/templates/space/apps/fe/src/styles/overrides/defaults/label.override.ts +91 -0
  162. package/templates/space/apps/fe/src/styles/overrides/defaults/modal.override.ts +57 -0
  163. package/templates/space/apps/fe/src/styles/overrides/defaults/radio.override.ts +46 -0
  164. package/templates/space/apps/fe/src/styles/overrides/defaults/table.override.ts +104 -0
  165. package/templates/space/apps/fe/src/styles/overrides/defaults/tag.override.ts +66 -0
  166. package/templates/space/apps/fe/src/styles/overrides/defaults/typography.override.ts +115 -0
  167. package/templates/space/apps/fe/src/styles/overrides/outline.clsx.ts +10 -0
  168. package/templates/space/apps/fe/src/styles/overrides/uiOverrides.override.ts +60 -0
  169. package/templates/space/apps/fe/src/styles/theme.css +2177 -0
  170. package/templates/space/apps/fe/src/types/i18next.d.ts +12 -0
  171. package/templates/space/apps/fe/src/types/table.d.ts +17 -0
  172. package/templates/space/apps/fe/src/types/ui.d.ts +26 -0
  173. package/templates/space/apps/fe/src/types/vite-env.d.ts +20 -0
  174. package/templates/space/apps/fe/src/utils/date.utils.ts +17 -0
  175. package/templates/space/apps/fe/src/utils/image-fallback.ts +9 -0
  176. package/templates/space/apps/fe/src/utils/media-upload.ts +63 -0
  177. package/templates/space/apps/fe/src/utils/number.utils.ts +12 -0
  178. package/templates/space/apps/fe/src/utils/string.utils.ts +5 -0
  179. package/templates/space/apps/fe/src/vite-env.d.ts +1 -0
  180. package/templates/space/apps/fe/tsconfig.app.json +32 -0
  181. package/templates/space/apps/fe/tsconfig.json +9 -0
  182. package/templates/space/apps/fe/tsconfig.node.json +31 -0
  183. package/templates/space/apps/fe/vite.config.ts +94 -0
  184. package/templates/space/database.ts +33 -13
  185. package/templates/space/openapi.json +1819 -0
  186. package/templates/space/oxfmt.config.js +23 -0
  187. package/templates/space/package.json +6 -13
  188. package/templates/space/rulesync.jsonc +18 -0
  189. package/templates/space/tsconfig.json +2 -4
  190. package/templates/space/api/health.ts +0 -7
  191. package/templates/space/api/launch.ts +0 -29
  192. package/templates/space/api/me.ts +0 -14
  193. package/templates/space/api/motto.ts +0 -9
  194. package/templates/space/api/rockets.ts +0 -43
  195. package/templates/space/index.html +0 -138
  196. package/templates/space/src/main.tsx +0 -229
  197. package/templates/space/vite.config.ts +0 -7
@@ -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
+ }
@@ -0,0 +1,79 @@
1
+ import { Typography, useSorting } from "@povio/ui";
2
+ import type { SortingState } from "@tanstack/react-table";
3
+ import { useCallback } from "react";
4
+ import { useTranslation } from "react-i18next";
5
+
6
+ import { LoadingState } from "@/components/shared/layout/LoadingState";
7
+ import type { PlanetModels } from "@/openapi/planet/planet.models";
8
+ import { PlanetQueries } from "@/openapi/planet/planet.queries";
9
+ import { StringUtils } from "@/utils/string.utils";
10
+
11
+ import { PlanetsTableInfinite } from "./table/PlanetsTableInfinite";
12
+ import { usePlanetTableFilters } from "./table/usePlanetTableFilters";
13
+ import { PlanetsTableSummary } from "./PlanetsTableSummary";
14
+
15
+ // Keep the first two pages tall enough for the intersection sentinel to leave and re-enter
16
+ // the viewport on larger collections. With 10-row pages it stayed visible after page two,
17
+ // so the observer never emitted another intersection and the loader remained indefinitely.
18
+ const PAGE_SIZE = 20;
19
+ const DEFAULT_SORTING: SortingState = [{ id: "discoveryDate", desc: true }];
20
+ const getPlanetRowId = (planet: PlanetModels.PlanetsGetResponseDto) => planet.id;
21
+
22
+ interface PlanetsInfiniteTablePageProps {
23
+ onEdit: (planet: PlanetModels.PlanetsGetResponseDto) => void;
24
+ }
25
+
26
+ export function PlanetsInfiniteTablePage({ onEdit }: PlanetsInfiniteTablePageProps) {
27
+ const { t } = useTranslation();
28
+
29
+ const { apiFilters, filterControls } = usePlanetTableFilters();
30
+
31
+ const { order, setSorting, sorting } = useSorting();
32
+ const effectiveSorting = sorting.length > 0 ? sorting : DEFAULT_SORTING;
33
+ const effectiveOrder = order || "-discoveryDate";
34
+
35
+ const { data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } =
36
+ PlanetQueries.usePaginateInfinite({
37
+ cursor: undefined,
38
+ limit: PAGE_SIZE,
39
+ filter: apiFilters,
40
+ order: StringUtils.emptyToUndefined(effectiveOrder),
41
+ });
42
+
43
+ const planets = data?.pages.flatMap((page) => page.items ?? []) ?? [];
44
+ const totalCount = data?.pages[0]?.totalItems ?? planets.length;
45
+ const handleFetchNextPage = useCallback(() => void fetchNextPage(), [fetchNextPage]);
46
+
47
+ return (
48
+ <div className="flex flex-col gap-4 pb-12">
49
+ {filterControls}
50
+
51
+ {isLoading ? (
52
+ <LoadingState />
53
+ ) : (
54
+ <PlanetsTableInfinite
55
+ className="min-w-[920px]"
56
+ items={planets}
57
+ getRowId={getPlanetRowId}
58
+ onRowClick={onEdit}
59
+ onEdit={onEdit}
60
+ setSorting={setSorting}
61
+ sorting={effectiveSorting}
62
+ hasNextPage={Boolean(hasNextPage)}
63
+ isFetchingNextPage={isFetchingNextPage}
64
+ fetchNextPage={handleFetchNextPage}
65
+ />
66
+ )}
67
+
68
+ {!isLoading && planets.length === 0 && (
69
+ <Typography size="body-3" className="text-text-default-2">
70
+ {t(($) => $.planets.page.empty)}
71
+ </Typography>
72
+ )}
73
+
74
+ {!isLoading ? (
75
+ <PlanetsTableSummary displayedCount={planets.length} totalCount={totalCount} />
76
+ ) : null}
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1,65 @@
1
+ import { Button } from "@povio/ui";
2
+ import { Plus } from "lucide-react";
3
+ import { useState } from "react";
4
+ import { useTranslation } from "react-i18next";
5
+
6
+ import { PlanetEditPage } from "@/components/features/planets/details/PlanetEditPage";
7
+ import { PageHeader } from "@/components/shared/page/PageHeader";
8
+ import type { PlanetModels } from "@/openapi/planet/planet.models";
9
+
10
+ import { PlanetCreateModal } from "./PlanetCreateModal";
11
+ import { PlanetsGridPage } from "./PlanetsGridPage";
12
+ import { PlanetsInfiniteTablePage } from "./PlanetsInfiniteTablePage";
13
+ import { PlanetsTablePage } from "./PlanetsTablePage";
14
+ import { type PlanetsView, PlanetsViewTabs } from "./PlanetsViewTabs";
15
+
16
+ export function PlanetsPage() {
17
+ const { t } = useTranslation();
18
+ const [view, setView] = useState<PlanetsView>("table");
19
+ const [isCreateOpen, setIsCreateOpen] = useState(false);
20
+ const [editingPlanet, setEditingPlanet] = useState<PlanetModels.PlanetsGetResponseDto>();
21
+
22
+ return (
23
+ <div className="flex flex-col gap-4">
24
+ <PageHeader
25
+ actions={
26
+ <Button
27
+ color="primary"
28
+ icon={Plus}
29
+ size="xs"
30
+ variant="contained"
31
+ onPress={() => setIsCreateOpen(true)}
32
+ >
33
+ {t(($) => $.planets.page.addPlanet)}
34
+ </Button>
35
+ }
36
+ title={t(($) => $.planets.page.title)}
37
+ />
38
+
39
+ <div className="flex w-full justify-between gap-1 border-elevation-outline-default-1 border-b pb-1-5">
40
+ <PlanetsViewTabs
41
+ value={view}
42
+ onChange={setView}
43
+ />
44
+ </div>
45
+
46
+ {view === "grid" && <PlanetsGridPage />}
47
+ {view === "table" && <PlanetsTablePage onEdit={setEditingPlanet} />}
48
+ {view === "infinite" && <PlanetsInfiniteTablePage onEdit={setEditingPlanet} />}
49
+
50
+ <PlanetCreateModal
51
+ isOpen={isCreateOpen}
52
+ onClose={() => setIsCreateOpen(false)}
53
+ />
54
+
55
+ {editingPlanet ? (
56
+ <PlanetEditPage
57
+ planet={editingPlanet}
58
+ onClose={() => setEditingPlanet(undefined)}
59
+ onDeleted={() => setEditingPlanet(undefined)}
60
+ onSaved={() => setEditingPlanet(undefined)}
61
+ />
62
+ ) : null}
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,72 @@
1
+ import { Typography, useSorting } from "@povio/ui";
2
+ import type { SortingState } from "@tanstack/react-table";
3
+ import { useTranslation } from "react-i18next";
4
+
5
+ import { LoadingState } from "@/components/shared/layout/LoadingState";
6
+ import type { PlanetModels } from "@/openapi/planet/planet.models";
7
+ import { PlanetQueries } from "@/openapi/planet/planet.queries";
8
+ import { StringUtils } from "@/utils/string.utils";
9
+
10
+ import { PlanetsTable } from "./table/PlanetsTable";
11
+ import { usePlanetTableFilters } from "./table/usePlanetTableFilters";
12
+ import { PlanetsTableSummary } from "./PlanetsTableSummary";
13
+
14
+ interface PlanetsTablePageProps {
15
+ onEdit: (planet: PlanetModels.PlanetsGetResponseDto) => void;
16
+ }
17
+
18
+ const DEFAULT_SORTING: SortingState = [{ id: "discoveryDate", desc: true }];
19
+ const EMPTY_PLANETS: PlanetModels.PlanetsGetResponseDto[] = [];
20
+ const getPlanetRowId = (planet: PlanetModels.PlanetsGetResponseDto) => planet.id;
21
+
22
+ export function PlanetsTablePage({ onEdit }: PlanetsTablePageProps) {
23
+ const { t } = useTranslation();
24
+
25
+ const { apiFilters, filterControls } = usePlanetTableFilters();
26
+
27
+ const { order, setSorting, sorting } = useSorting();
28
+ const effectiveSorting = sorting.length > 0 ? sorting : DEFAULT_SORTING;
29
+ const effectiveOrder = order || "-discoveryDate";
30
+
31
+ const { data, isLoading } = PlanetQueries.usePaginate({
32
+ page: 1,
33
+ cursor: undefined,
34
+ limit: 20,
35
+ filter: apiFilters,
36
+ order: StringUtils.emptyToUndefined(effectiveOrder),
37
+ });
38
+ const planets = data?.items ?? EMPTY_PLANETS;
39
+
40
+ return (
41
+ <div className="flex flex-col gap-4 pb-12">
42
+ {filterControls}
43
+
44
+ {isLoading ? (
45
+ <LoadingState />
46
+ ) : (
47
+ <PlanetsTable
48
+ className="min-w-[920px]"
49
+ items={planets}
50
+ getRowId={getPlanetRowId}
51
+ onRowClick={onEdit}
52
+ onEdit={onEdit}
53
+ setSorting={setSorting}
54
+ sorting={effectiveSorting}
55
+ />
56
+ )}
57
+
58
+ {!isLoading && planets.length === 0 && (
59
+ <Typography size="body-3" className="text-text-default-2">
60
+ {t(($) => $.planets.page.empty)}
61
+ </Typography>
62
+ )}
63
+
64
+ {!isLoading ? (
65
+ <PlanetsTableSummary
66
+ displayedCount={planets.length}
67
+ totalCount={data?.totalItems ?? planets.length}
68
+ />
69
+ ) : null}
70
+ </div>
71
+ );
72
+ }
@@ -0,0 +1,29 @@
1
+ import { Typography } from "@povio/ui";
2
+ import { useTranslation } from "react-i18next";
3
+
4
+ interface PlanetsTableSummaryProps {
5
+ displayedCount: number;
6
+ totalCount: number;
7
+ }
8
+
9
+ export function PlanetsTableSummary({ displayedCount, totalCount }: PlanetsTableSummaryProps) {
10
+ const { t } = useTranslation();
11
+
12
+ if (totalCount === 0) {
13
+ return null;
14
+ }
15
+
16
+ return (
17
+ <div className="fixed inset-x-0 bottom-0 z-sticky flex min-h-10 justify-end gap-2 border-elevation-outline-default-1 border-t bg-elevation-fill-default-1 px-2 py-3 shadow-1">
18
+ {displayedCount >= totalCount ? (
19
+ <Typography size="label-3">{t(($) => $.planets.table.allLoaded)}</Typography>
20
+ ) : null}
21
+ <Typography
22
+ className="text-text-default-3"
23
+ size="label-3"
24
+ >
25
+ {t(($) => $.planets.table.showing, { displayed: displayedCount, total: totalCount })}
26
+ </Typography>
27
+ </div>
28
+ );
29
+ }