diff --git a/frontend/src/components/shared/modals/confirmation-modal.tsx b/frontend/src/components/shared/modals/confirmation-modal.tsx new file mode 100644 index 00000000..9d277ee6 --- /dev/null +++ b/frontend/src/components/shared/modals/confirmation-modal.tsx @@ -0,0 +1,57 @@ +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { ButtonVariant, SHOELACE_SIZES } from "@/enums"; +import useScreenSize from "@/hooks/use-screen-size"; + +type ConfirmationModalProps = { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + message: string; + icon: React.ReactNode; + isConfirming?: boolean; + confirmLabel?: string; + cancelLabel?: string; +}; + +export const ConfirmationModal = ({ + isOpen, + onClose, + onConfirm, + message, + icon, + isConfirming = false, + confirmLabel = "Confirm", + cancelLabel = "Cancel", +}: ConfirmationModalProps) => { + const { isMobile } = useScreenSize(); + + if (!isOpen) return null; + + return ( + +
+ {icon} +

{message}

+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/components/shared/modals/index.ts b/frontend/src/components/shared/modals/index.ts index c4a5380d..4f989826 100644 --- a/frontend/src/components/shared/modals/index.ts +++ b/frontend/src/components/shared/modals/index.ts @@ -1 +1,3 @@ export { DeleteModal } from "./delete-modal"; +export { ConfirmationModal } from "./confirmation-modal"; +export { SuccessModal } from "./success-modal"; diff --git a/frontend/src/components/shared/modals/success-modal.tsx b/frontend/src/components/shared/modals/success-modal.tsx new file mode 100644 index 00000000..ed3e2fa6 --- /dev/null +++ b/frontend/src/components/shared/modals/success-modal.tsx @@ -0,0 +1,41 @@ +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { SHOELACE_SIZES } from "@/enums"; +import useScreenSize from "@/hooks/use-screen-size"; + +type SuccessModalProps = { + isOpen: boolean; + onClose: () => void; + message: string; + icon: React.ReactNode; + closeLabel?: string; +}; + +export const SuccessModal = ({ + isOpen, + onClose, + message, + icon, + closeLabel = "Done", +}: SuccessModalProps) => { + const { isMobile } = useScreenSize(); + + if (!isOpen) return null; + + return ( + +
+ {icon} +

{message}

+ +
+
+ ); +}; diff --git a/frontend/src/components/shared/training-status-badge.tsx b/frontend/src/components/shared/training-status-badge.tsx index e6c933d4..c4b8411e 100644 --- a/frontend/src/components/shared/training-status-badge.tsx +++ b/frontend/src/components/shared/training-status-badge.tsx @@ -8,6 +8,7 @@ export const TrainingStatusBadge = ({ status }: { status: string }) => { submitted: "blue", running: "yellow", pending: "yellow", + published: "green", }; return ( diff --git a/frontend/src/components/ui/icons/warning-icon.tsx b/frontend/src/components/ui/icons/warning-icon.tsx new file mode 100644 index 00000000..ee669b7c --- /dev/null +++ b/frontend/src/components/ui/icons/warning-icon.tsx @@ -0,0 +1,18 @@ +import { IconProps, ShoelaceSlotProps } from "@/types"; +import React from "react"; + +export const WarningIcon: React.FC = (props) => ( + + + +); diff --git a/frontend/src/components/ui/icons/wavy-check-icon.tsx b/frontend/src/components/ui/icons/wavy-check-icon.tsx new file mode 100644 index 00000000..46346a63 --- /dev/null +++ b/frontend/src/components/ui/icons/wavy-check-icon.tsx @@ -0,0 +1,20 @@ +import { IconProps, ShoelaceSlotProps } from "@/types"; +import React from "react"; + +export const WavyCheckIcon: React.FC = ( + props, +) => ( + + + +); diff --git a/frontend/src/features/user-profile/api/predictions.ts b/frontend/src/features/user-profile/api/predictions.ts index 8780b8a5..b734731d 100644 --- a/frontend/src/features/user-profile/api/predictions.ts +++ b/frontend/src/features/user-profile/api/predictions.ts @@ -55,3 +55,36 @@ export const useRetryOfflinePrediction = ({ mutationFn: retryOfflinePrediction, }); }; + +export const publishPrediction = ({ + predictionId, + published, +}: { + predictionId: number; + published: boolean; +}) => { + return apiClient.patch( + API_ENDPOINTS.PUBLISH_OFFLINE_PREDICTION(predictionId), + { published }, + ); +}; + +type TUsePublishPredictionOptions = { + mutationConfig?: MutationConfig; +}; +export const usePublishPrediction = ({ + mutationConfig, +}: TUsePublishPredictionOptions) => { + const queryClient = useQueryClient(); + const { onSuccess, ...restConfig } = mutationConfig || {}; + return useMutation({ + onSuccess: async (...args) => { + onSuccess?.(...args); + queryClient.invalidateQueries({ + queryKey: ["offline-predictions"], + }); + }, + ...restConfig, + mutationFn: publishPrediction, + }); +}; diff --git a/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-card.tsx b/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-card.tsx index 93a68d64..93a98a2e 100644 --- a/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-card.tsx +++ b/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-card.tsx @@ -6,6 +6,7 @@ import { formatDate, formatDuration, formatNumber } from "@/utils"; import { OfflinePredictionActions } from "./offline-predictions-actions"; import { MapIcon } from "@/components/ui/icons"; import { MapSwipeProjectIsActive } from "./mapswipe-project-active"; +import { getDisplayStatus } from "@/features/user-profile/utils/get-display-status"; export const OfflinePredictionCard = ({ predictionResult, @@ -41,7 +42,13 @@ export const OfflinePredictionCard = ({ } /> - + +
+ } + /> + ); + } + + return ( + + + + } + /> + ); +}; diff --git a/frontend/src/features/user-profile/utils/get-display-status.ts b/frontend/src/features/user-profile/utils/get-display-status.ts new file mode 100644 index 00000000..07dfeef1 --- /dev/null +++ b/frontend/src/features/user-profile/utils/get-display-status.ts @@ -0,0 +1,18 @@ +import { ModelTrainingStatus } from "@/enums"; + +/** + * Determines the display status of a prediction. + * + * When a prediction is finished AND published, the display status should + * be "PUBLISHED" instead of the underlying API status. For all other cases + * (for other statuses), return the API status as-is. + */ +export const getDisplayStatus = ( + status: ModelTrainingStatus, + published: boolean, +): string => { + if (status === ModelTrainingStatus.FINISHED && published) { + return "PUBLISHED"; + } + return status; +}; diff --git a/frontend/src/services/api-routes.ts b/frontend/src/services/api-routes.ts index ac9fc42b..34234467 100644 --- a/frontend/src/services/api-routes.ts +++ b/frontend/src/services/api-routes.ts @@ -107,6 +107,7 @@ export const API_ENDPOINTS = { `workspace/download/training_${trainingId}/${directory_name}/`, TERMINATE_OFFLINE_PREDICTION: (id: number) => `prediction/terminate/${id}/`, RETRY_OFFLINE_PREDICTION: (id: number) => `prediction/${id}/retry/`, + PUBLISH_OFFLINE_PREDICTION: (id: number) => `prediction/${id}/`, // Notifications NOTIFICATIONS: "notifications/me", diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 4cc2edfc..2dd97fa8 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -27,6 +27,7 @@ body { --hot-fair-color-green-primary: #198155; --hot-fair-color-frosted-blue: #f7f9fb; --hot-fair-color-ink: #202325; + --hot-fair-color-yellow-secondary: #fffddb; /* Font sizes in rem */ --hot-fair-font-size-extra-large: 4.25rem; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index aa65292d..d34b2a1b 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -271,6 +271,7 @@ export type TOfflinePrediction = { user: number; config: TModelPredictionsConfig; result_count: number; + published: boolean; result: null | { count: number; }; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 8d30b66a..2ce7a959 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -15,7 +15,8 @@ export default { "hover-accent": "var( --hot-fair-color-hover-accent)", "green-secondary": "var(--hot-fair-color-green-secondary)", "green-primary": "var(--hot-fair-color-green-primary)", - "frosted-blue": "var(--hot-fair-color-frosted-blue)" + "frosted-blue": "var(--hot-fair-color-frosted-blue)", + "secondary-yellow": "var(--hot-fair-color-yellow-secondary)", }, fontFamily: { archivo: "var(--sl-font-sans)",