diff --git a/gatsby-browser.tsx b/gatsby-browser.tsx index 69d800409..0a7bec8b1 100644 --- a/gatsby-browser.tsx +++ b/gatsby-browser.tsx @@ -3,6 +3,7 @@ import React from "react"; import ErrorBoundary from "./src/development-kit/error-boundary"; import { useAuth } from "./src/core/use-auth"; import { CookiesModalLoader } from "./src/components/cookies-modal-loader"; +import { ToastSlot } from "./src/design-system/toast"; import "katex/dist/katex.min.css"; import "prismjs/themes/prism-okaidia.css"; import "./src/style/index.css"; @@ -27,6 +28,7 @@ export const wrapPageElement = ({ element }) => { {element} + ); }; diff --git a/package-lock.json b/package-lock.json index e07572af5..f5ec7e4a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "classnames": "^2.5.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", - "firebase": "^12.4.0", + "firebase": "^12.5.0", "gatsby": "^5.15.0", "gatsby-plugin-manifest": "^5.15.0", "gatsby-plugin-mdx": "^5.15.0", @@ -2773,9 +2773,9 @@ "license": "MIT" }, "node_modules/@firebase/ai": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.4.0.tgz", - "integrity": "sha512-YilG6AJ/nYpCKtxZyvEzBRAQv5bU+2tBOKX4Ps0rNNSdxN39aT37kGhjATbk1kq1z5Lq7mkWglw/ajAF3lOWUg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.5.0.tgz", + "integrity": "sha512-OXv/jZLRjV9jTejWA4KOvW8gM1hNsLvQSCPwKhi2CEfe0Nap3rM6z+Ial0PGqXga0WgzhpypEvJOFvaAUFX3kg==", "license": "Apache-2.0", "dependencies": { "@firebase/app-check-interop-types": "0.3.3", @@ -2831,9 +2831,9 @@ "license": "Apache-2.0" }, "node_modules/@firebase/app": { - "version": "0.14.4", - "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.4.tgz", - "integrity": "sha512-pUxEGmR+uu21OG/icAovjlu1fcYJzyVhhT0rsCrn+zi+nHtrS43Bp9KPn9KGa4NMspCUE++nkyiqziuIvJdwzw==", + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.5.tgz", + "integrity": "sha512-zyNY77xJOGwcuB+xCxF8z8lSiHvD4ox7BCsqLEHEvgqQoRjxFZ0fkROR6NV5QyXmCqRLodMM8J5d2EStOocWIw==", "license": "Apache-2.0", "dependencies": { "@firebase/component": "0.7.0", @@ -2897,12 +2897,12 @@ "license": "Apache-2.0" }, "node_modules/@firebase/app-compat": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.4.tgz", - "integrity": "sha512-T7ifGmb+awJEcp542Ek4HtNfBxcBrnuk1ggUdqyFEdsXHdq7+wVlhvE6YukTL7NS8hIkEfL7TMAPx/uCNqt30g==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.5.tgz", + "integrity": "sha512-lVG/nRnXaot0rQSZazmTNqy83ti9O3+kdwoaE0d5wahRIWNoDirbIMcGVjDDgdmf4IE6FYreWOMh0L3DV1475w==", "license": "Apache-2.0", "dependencies": { - "@firebase/app": "0.14.4", + "@firebase/app": "0.14.5", "@firebase/component": "0.7.0", "@firebase/logger": "0.5.0", "@firebase/util": "1.13.0", @@ -2919,9 +2919,9 @@ "license": "Apache-2.0" }, "node_modules/@firebase/auth": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.11.0.tgz", - "integrity": "sha512-5j7+ua93X+IRcJ1oMDTClTo85l7Xe40WSkoJ+shzPrX7OISlVWLdE1mKC57PSD+/LfAbdhJmvKixINBw2ESK6w==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.11.1.tgz", + "integrity": "sha512-Mea0G/BwC1D0voSG+60Ylu3KZchXAFilXQ/hJXWCw3gebAu+RDINZA0dJMNeym7HFxBaBaByX8jSa7ys5+F2VA==", "license": "Apache-2.0", "dependencies": { "@firebase/component": "0.7.0", @@ -2943,12 +2943,12 @@ } }, "node_modules/@firebase/auth-compat": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.0.tgz", - "integrity": "sha512-J0lGSxXlG/lYVi45wbpPhcWiWUMXevY4fvLZsN1GHh+po7TZVng+figdHBVhFheaiipU8HZyc7ljw1jNojM2nw==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.1.tgz", + "integrity": "sha512-I0o2ZiZMnMTOQfqT22ur+zcGDVSAfdNZBHo26/Tfi8EllfR1BO7aTVo2rt/ts8o/FWsK8pOALLeVBGhZt8w/vg==", "license": "Apache-2.0", "dependencies": { - "@firebase/auth": "1.11.0", + "@firebase/auth": "1.11.1", "@firebase/auth-types": "0.13.0", "@firebase/component": "0.7.0", "@firebase/util": "1.13.0", @@ -14277,21 +14277,21 @@ } }, "node_modules/firebase": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.4.0.tgz", - "integrity": "sha512-/chNgDQ6ppPPGOQO4jctxOa/5JeQxuhaxA7Y90K0I+n/wPfoO8mRveedhVUdo7ExLcWUivnnow/ouSLYSI5Icw==", + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.5.0.tgz", + "integrity": "sha512-Ak8JcpH7FL6kiv0STwkv5+3CYEROO9iFWSx7OCZVvc4kIIABAIyAGs1mPGaHRxGUIApFZdMCXA7baq17uS6Mow==", "license": "Apache-2.0", "dependencies": { - "@firebase/ai": "2.4.0", + "@firebase/ai": "2.5.0", "@firebase/analytics": "0.10.19", "@firebase/analytics-compat": "0.2.25", - "@firebase/app": "0.14.4", + "@firebase/app": "0.14.5", "@firebase/app-check": "0.11.0", "@firebase/app-check-compat": "0.4.0", - "@firebase/app-compat": "0.5.4", + "@firebase/app-compat": "0.5.5", "@firebase/app-types": "0.9.3", - "@firebase/auth": "1.11.0", - "@firebase/auth-compat": "0.6.0", + "@firebase/auth": "1.11.1", + "@firebase/auth-compat": "0.6.1", "@firebase/data-connect": "0.3.11", "@firebase/database": "1.1.0", "@firebase/database-compat": "2.1.0", diff --git a/package.json b/package.json index 8e4378427..42f62054e 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "classnames": "^2.5.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", - "firebase": "^12.4.0", + "firebase": "^12.5.0", "gatsby": "^5.15.0", "gatsby-plugin-manifest": "^5.15.0", "gatsby-plugin-mdx": "^5.15.0", diff --git a/src/api-4markdown-contracts/contracts.ts b/src/api-4markdown-contracts/contracts.ts index 114ca6406..2d1ed2e96 100644 --- a/src/api-4markdown-contracts/contracts.ts +++ b/src/api-4markdown-contracts/contracts.ts @@ -10,7 +10,7 @@ import type { import { AccessGroupDto, Atoms, - CommentDto, + UserProfileCommentDto, FullMindmapDto, ImageDto, MindmapDto, @@ -149,6 +149,22 @@ type ResourceCompletionsContracts = >; type UserProfilesContracts = + | Contract< + "rateUserProfile", + null, + { + userProfileId: Atoms["UserProfileId"]; + category: Atoms["RatingCategory"]; + } + > + | Contract< + "addUserProfileScore", + Atoms["Score"], + { + userProfileId: Atoms["UserProfileId"]; + score: Atoms["ScoreValue"]; + } + > | Contract< `getYourUserProfile`, { @@ -185,27 +201,38 @@ type UserProfilesContracts = `getUserProfile`, { profile: UserProfileDto; - comments: CommentDto[]; + comments: UserProfileCommentDto[]; }, { profileId: Atoms["UserProfileId"]; } > + | Contract< + "findUserProfiles", + { + hasMore: boolean; + userProfiles: UserProfileDto[]; + }, + { query: string; by: "displayName" | "id"; limit?: number } + >; + +type UserProfileCommentsContracts = | Contract< `addUserProfileComment`, - CommentDto, + UserProfileCommentDto, { receiverProfileId: Atoms["UserProfileId"]; comment: string; } > | Contract< - "findUserProfiles", + `rateUserProfileComment`, + null, { - hasMore: boolean; - userProfiles: UserProfileDto[]; - }, - { query: string; by: "displayName" | "id"; limit?: number } + profileId: Atoms["UserProfileId"]; + commentId: Atoms["UserProfileCommentId"]; + category: Atoms["RatingCategory"]; + } >; type AccountsContracts = Contract<`getYourAccount`, YourAccountDto>; @@ -361,7 +388,8 @@ type API4MarkdownContracts = | AccountsContracts | ResourceCompletionsContracts | AccessGroupsContracts - | UserProfilesContracts; + | UserProfilesContracts + | UserProfileCommentsContracts; export type API4MarkdownContractKey = API4MarkdownContracts["key"]; export type API4MarkdownDto = Extract< diff --git a/src/api-4markdown-contracts/dtos.ts b/src/api-4markdown-contracts/dtos.ts index cffb60f92..50c1a4c04 100644 --- a/src/api-4markdown-contracts/dtos.ts +++ b/src/api-4markdown-contracts/dtos.ts @@ -13,12 +13,13 @@ export type Atoms = { ResourceType: "document" | "mindmap" | "mindmap-node"; ResourceVisibility: "private" | "public" | "permanent" | "manual"; RatingCategory: "ugly" | "bad" | "decent" | "good" | "perfect"; + ScoreValue: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; ImageId: Brand; Path: Brand; Slug: Brand; Url: Brand; RewriteAssistantPersona: "cleany" | "grammy" | "teacher"; - CommentId: Brand; + UserProfileCommentId: Brand; AvatarVariantId: Brand; AvatarVariant: { w: number; @@ -28,6 +29,11 @@ export type Atoms = { }; DocumentCommentId: Brand; Rating: Record; + Score: { + scoreAverage: number; + scoreCount: number; + scoreValues: Atoms["ScoreValue"][]; + }; }; export type AccessGroupDto = { @@ -59,24 +65,27 @@ export type ImageDto = { id: Atoms["ImageId"]; }; -export type UserProfileDto = { - id: Atoms["UserProfileId"]; - cdate: Atoms["UTCDate"]; - mdate: Atoms["UTCDate"]; - displayNameSlug: Atoms["Slug"] | null; - displayName: string | null; - bio: string | null; - avatar: Record<"tn" | "sm" | "md" | "lg", Atoms["AvatarVariant"]> | null; - githubUrl: Atoms["Url"] | null; - linkedInUrl: Atoms["Url"] | null; - twitterUrl: Atoms["Url"] | null; - fbUrl: Atoms["Url"] | null; - blogUrl: Atoms["Url"] | null; -}; +export type UserProfileDto = Prettify< + { + id: Atoms["UserProfileId"]; + cdate: Atoms["UTCDate"]; + mdate: Atoms["UTCDate"]; + displayNameSlug: Atoms["Slug"] | null; + displayName: string | null; + bio: string | null; + avatar: Record<"tn" | "sm" | "md" | "lg", Atoms["AvatarVariant"]> | null; + githubUrl: Atoms["Url"] | null; + linkedInUrl: Atoms["Url"] | null; + twitterUrl: Atoms["Url"] | null; + fbUrl: Atoms["Url"] | null; + blogUrl: Atoms["Url"] | null; + } & Partial & + Partial +>; -export type CommentDto = Prettify< +export type UserProfileCommentDto = Prettify< Atoms["Rating"] & { - id: Atoms["CommentId"]; + id: Atoms["UserProfileCommentId"]; ownerProfile: UserProfileDto; cdate: Atoms["UTCDate"]; mdate: Atoms["UTCDate"]; diff --git a/src/api-4markdown/__tests__/parse-error.test.ts b/src/api-4markdown/__tests__/parse-error.test.ts index 8bf2c4408..aea61b624 100644 --- a/src/api-4markdown/__tests__/parse-error.test.ts +++ b/src/api-4markdown/__tests__/parse-error.test.ts @@ -1,5 +1,5 @@ import type { API4MarkdownError } from "api-4markdown-contracts"; -import { parseError } from "../parse-error"; +import { parseError } from "../errors"; import { expect } from "@jest/globals"; describe(`Error parsing works when`, () => { diff --git a/src/api-4markdown/parse-error.ts b/src/api-4markdown/errors.ts similarity index 60% rename from src/api-4markdown/parse-error.ts rename to src/api-4markdown/errors.ts index 7cc9fde36..22e556e11 100644 --- a/src/api-4markdown/parse-error.ts +++ b/src/api-4markdown/errors.ts @@ -1,5 +1,7 @@ import type { API4MarkdownError } from "api-4markdown-contracts"; +class CustomError extends Error {} + const parseError = (error: unknown): API4MarkdownError => { const unknownError: Extract = { symbol: `unknown`, @@ -7,6 +9,14 @@ const parseError = (error: unknown): API4MarkdownError => { message: `Unknown error occured`, }; + if (error instanceof CustomError) { + return { + symbol: `custom-error`, + content: error.message, + message: error.message, + }; + } + if (!(error instanceof Error)) { return unknownError; } @@ -18,4 +28,6 @@ const parseError = (error: unknown): API4MarkdownError => { } }; -export { parseError }; +const customError = (message: string): CustomError => new CustomError(message); + +export { parseError, customError }; diff --git a/src/api-4markdown/index.ts b/src/api-4markdown/index.ts index b3e627827..d8a6bc7fb 100644 --- a/src/api-4markdown/index.ts +++ b/src/api-4markdown/index.ts @@ -1,4 +1,4 @@ -export { parseError } from "./parse-error"; +export { parseError, customError } from "./errors"; export { observe, emit, unobserveAll } from "./observer"; export { initializeAPI, getAPI } from "./use-api"; export { getCache, removeCache, setCache, type CacheVersion } from "./cache"; diff --git a/src/components/rate-picker.tsx b/src/components/rate-picker.tsx new file mode 100644 index 000000000..05245364e --- /dev/null +++ b/src/components/rate-picker.tsx @@ -0,0 +1,53 @@ +import { Atoms } from "api-4markdown-contracts"; +import { RATING_ICONS } from "core/rating-config"; +import { Button } from "design-system/button"; +import { c } from "design-system/c"; +import { playNote } from "development-kit/play-note"; +import React from "react"; + +type RatePickerProps = { + className?: string; + rating?: Partial>; + disabled?: boolean; + rate: Atoms["RatingCategory"] | null; + onRate: (category: Atoms["RatingCategory"], index: number) => void; +}; + +const RATE_NOTES = ["c4", "d4", "e4", "f4", "g4"] as const; + +const RatePicker = ({ + className, + rate, + disabled, + rating = {}, + onRate, +}: RatePickerProps) => { + const rateAndPlay = async ( + category: Atoms["RatingCategory"], + index: number, + ): Promise => { + playNote(RATE_NOTES[index]); + onRate(category, index); + }; + + return ( +
+ {RATING_ICONS.map(([Icon, category], idx) => ( + + ))} +
+ ); +}; + +export { RatePicker }; diff --git a/src/components/score-picker.tsx b/src/components/score-picker.tsx new file mode 100644 index 000000000..9d75b54c5 --- /dev/null +++ b/src/components/score-picker.tsx @@ -0,0 +1,160 @@ +import { c } from "design-system/c"; +import React from "react"; +import Popover from "design-system/popover"; +import { useSimpleFeature } from "@greenonsoftware/react-kit"; +import { playNote } from "development-kit/play-note"; +import { Atoms } from "api-4markdown-contracts"; + +type ScorePickerProps = { + average?: number | null; + count?: number | null; + className?: string; + popoverClassName?: string; + children?: React.ReactNode; + onRate: (score: Atoms["ScoreValue"]) => void; +} & React.DetailedHTMLProps< + React.ButtonHTMLAttributes, + HTMLButtonElement +>; + +const getGradientClasses = (score: number): string => { + if (score === 0) { + return "bg-gradient-to-r from-gray-500 via-gray-400 to-gray-500 dark:from-gray-800 dark:via-gray-700 dark:to-gray-800"; + } + + const normalizedScore = Math.max(0, Math.min(10, score)); + + if (normalizedScore <= 2) { + return "bg-gradient-to-r from-red-600 via-red-500 to-orange-600 dark:from-red-900 dark:via-red-800 dark:to-orange-900"; + } else if (normalizedScore <= 4) { + return "bg-gradient-to-r from-red-500 via-orange-600 to-orange-500 dark:from-red-800 dark:via-orange-900 dark:to-orange-800"; + } else if (normalizedScore <= 6) { + return "bg-gradient-to-r from-orange-500 via-amber-500 to-yellow-500 dark:from-orange-800 dark:via-amber-800 dark:to-yellow-700"; + } else if (normalizedScore <= 8) { + return "bg-gradient-to-r from-yellow-500 via-lime-500 to-green-600 dark:from-yellow-700 dark:via-lime-700 dark:to-green-800"; + } else { + return "bg-gradient-to-r from-green-600 via-green-500 to-emerald-600 dark:from-green-800 dark:via-green-700 dark:to-emerald-800"; + } +}; + +const SCORE_NOTES = [ + "c4", + "d4", + "e4", + "f4", + "g4", + "g#4", + "a4", + "a#4", + "b4", + "c5", +] as const; + +const ScorePicker = ({ + average, + count, + className, + popoverClassName, + children, + onRate, + type = "button", + onClick, + disabled, + ...props +}: ScorePickerProps) => { + const panel = useSimpleFeature(); + const finalScore = average ?? 0; + const gradientClasses = getGradientClasses(finalScore); + const displayText = finalScore === 0 ? "N/A" : `${finalScore}/10`; + const finalVotes = count ?? 0; + + const handleClick = (e: React.MouseEvent) => { + panel.on(); + onClick?.(e); + }; + + const handleRateClick = (score: Atoms["ScoreValue"], index: number) => { + onRate?.(score); + playNote(SCORE_NOTES[index]); + panel.off(); + }; + + return ( +
+ + {panel.isOn && ( + + {Array.from({ length: SCORE_NOTES.length }, (_, i) => { + const score = (i + 1) as Atoms["ScoreValue"]; + const classes = getGradientClasses(score); + return ( + + ); + })} + + )} +
+ ); +}; + +export { ScorePicker }; diff --git a/src/containers/document-rating.container.tsx b/src/containers/document-rating.container.tsx index 02c13de34..7a9520c12 100644 --- a/src/containers/document-rating.container.tsx +++ b/src/containers/document-rating.container.tsx @@ -1,11 +1,9 @@ import React from "react"; -import c from "classnames"; -import { Button } from "design-system/button"; -import { RATING_ICONS } from "core/rating-config"; import { useDocumentLayoutContext } from "providers/document-layout.provider"; import { rateDocumentAct } from "acts/rate-document.act"; import throttle from "lodash.throttle"; import { Atoms } from "api-4markdown-contracts"; +import { RatePicker } from "components/rate-picker"; type DocumentRatingContainerProps = { className?: string; @@ -13,35 +11,6 @@ type DocumentRatingContainerProps = { const rateDocument = throttle(rateDocumentAct, 5000); -const NOTES = [ - { name: `C4`, frequency: 261.63 }, - { name: `D4`, frequency: 293.66 }, - { name: `E4`, frequency: 329.63 }, - { name: `F4`, frequency: 349.23 }, - { name: `G4`, frequency: 392.0 }, -]; - -const playNote = (frequency: number): void => { - const audioContext = new (window.AudioContext || window.webkitAudioContext)(); - - if (!audioContext) return; - - const oscillator = audioContext.createOscillator(); - const gainNode = audioContext.createGain(); - - oscillator.type = `sine`; - oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime); - oscillator.connect(gainNode); - gainNode.connect(audioContext.destination); - - oscillator.start(); - gainNode.gain.exponentialRampToValueAtTime( - 0.00001, - audioContext.currentTime + 1, - ); - oscillator.stop(audioContext.currentTime + 1); -}; - const DocumentRatingContainer = ({ className, }: DocumentRatingContainerProps) => { @@ -50,9 +19,7 @@ const DocumentRatingContainer = ({ const handleClick = async ( category: Atoms["RatingCategory"], - index: number, ): Promise => { - playNote(NOTES[index].frequency); rateDocument({ documentId: document.id, category }); setDocumentLayoutState(({ document, yourRate }) => { if (yourRate === null) { @@ -95,28 +62,13 @@ const DocumentRatingContainer = ({ }); }; - React.useEffect(() => { - return () => { - rateDocument.cancel(); - }; - }, []); - return ( -
- {RATING_ICONS.map(([Icon, category], idx) => ( - - ))} -
+ ); }; diff --git a/src/design-system/toast.tsx b/src/design-system/toast.tsx new file mode 100644 index 000000000..59902090f --- /dev/null +++ b/src/design-system/toast.tsx @@ -0,0 +1,286 @@ +import React from "react"; + +import { Button } from "./button"; +import type { CSSProperties } from "react"; +import { usePortal } from "development-kit/use-portal"; +import { c } from "./c"; +import { BiX } from "react-icons/bi"; +import { BehaviorSubject } from "rxjs"; + +const getToastClassName = ({ + variant = "informative", + animated = true, + mode = "static", + position = "bottom-left", + className, +}: { + variant?: "informative" | "warning" | "error" | "success"; + animated?: boolean; + mode?: "static" | "outside"; + position?: + | "top-left" + | "top-center" + | "top-right" + | "bottom-left" + | "bottom-center" + | "bottom-right"; + className?: string; +}) => { + const baseClasses = + "rounded-lg border-l-4 py-3 px-4 flex flex-col min-w-[17.5rem] bg-white dark:bg-slate-900 shadow-lg"; + + const variantClasses = { + informative: "border-l-blue-500", + warning: "border-l-yellow-500", + error: "border-l-red-500", + success: "border-l-green-500", + }; + + const animatedClasses = animated ? "" : ""; + + const modeClasses = mode === "outside" ? "z-50 fixed" : ""; + + const positionClasses = + mode === "outside" + ? { + "top-left": "left-4 top-4", + "top-center": "left-1/2 -translate-x-1/2 top-4", + "top-right": "right-4 top-4", + "bottom-left": "left-4 bottom-4", + "bottom-center": "left-1/2 -translate-x-1/2 bottom-4", + "bottom-right": "right-4 bottom-4", + }[position] + : ""; + + return c( + baseClasses, + variantClasses[variant], + animatedClasses, + modeClasses, + positionClasses, + className, + ); +}; + +type ToastProps = React.ComponentProps<"div"> & { + variant?: "informative" | "warning" | "error" | "success"; + title: string; + role?: "alert" | "status"; + animated?: boolean; + mode?: "static" | "outside"; + position?: + | "top-left" + | "top-center" + | "top-right" + | "bottom-left" + | "bottom-center" + | "bottom-right"; + onClose?(): void; +}; + +type ToastOptions = Omit & { + duration?: number | "infinite"; + onClose?(id: string): void; +}; + +type ActiveToast = ToastOptions & { id: string }; + +const toasts$ = new BehaviorSubject([]); +let toastId = 0; +const timeoutIds = new Map(); + +const addToast = (options: ToastOptions) => { + const id = (toastId++).toString(); + const newToast = { ...options, id }; + + const currentToasts = toasts$.getValue(); + toasts$.next([...currentToasts, newToast]); + + if (options.duration !== "infinite") { + const duration = options.duration ?? 5000; + const timeoutId = setTimeout(() => removeToast(id), duration); + timeoutIds.set(id, timeoutId); + } +}; + +const removeToast = (id: string) => { + if (timeoutIds.has(id)) { + clearTimeout(timeoutIds.get(id)!); + timeoutIds.delete(id); + } + + const currentToasts = toasts$.getValue(); + const initialCount = currentToasts.length; + const newToasts = currentToasts.filter((t) => t.id !== id); + + if (newToasts.length < initialCount) { + toasts$.next(newToasts); + } +}; + +const toast = (options: ToastOptions) => addToast(options); + +toast.success = (options: Omit) => { + toast({ ...options, variant: "success" }); +}; + +toast.error = (options: Omit) => { + toast({ ...options, variant: "error", role: "alert" }); +}; + +toast.warning = (options: Omit) => { + toast({ ...options, variant: "warning", role: "alert" }); +}; + +toast.info = (options: Omit) => { + toast({ ...options, variant: "informative", role: "status" }); +}; + +const Toast = React.forwardRef( + ( + { + className, + variant, + children, + title, + mode, + role = "alert", + position, + animated, + onClose, + ...props + }, + ref, + ) => { + const defaultPosition = position ?? "bottom-left"; + const uniqueId = React.useId(); + const titleId = `${uniqueId}-title`; + const descriptionId = `${uniqueId}-description`; + const { render } = usePortal(); + + const content = ( + // biome-ignore lint/a11y/useAriaPropsSupportedByRole: +
+
+ + {title} + + {onClose && ( + + )} +
+ {children && ( +
+ {children} +
+ )} +
+ ); + + if (mode === "outside") { + return render(content); + } + + return content; + }, +); + +const ToastSlot = () => { + const [allToasts, setAllToasts] = React.useState([]); + + React.useEffect(() => { + const subscription = toasts$.subscribe(setAllToasts); + return () => subscription.unsubscribe(); + }, []); + + if (allToasts.length === 0) { + return null; + } + + const groupedToasts = allToasts.reduce( + (acc, toast) => { + const position = toast.position ?? "bottom-left"; + if (!acc[position]) { + acc[position] = []; + } + acc[position].push(toast); + return acc; + }, + {} as Record, ActiveToast[]>, + ); + + return ( + <> + {Object.entries(groupedToasts).map(([position, toastsInGroup]) => ( + + {toastsInGroup.map(({ id, ...props }, idx) => ( + { + props.onClose?.(id); + removeToast(id); + }} + /> + ))} + + ))} + + ); +}; + +Toast.displayName = "Toast"; + +export type { ToastProps }; +export { Toast, ToastSlot, toast }; diff --git a/src/development-kit/play-note.ts b/src/development-kit/play-note.ts new file mode 100644 index 000000000..dcb65c762 --- /dev/null +++ b/src/development-kit/play-note.ts @@ -0,0 +1,152 @@ +let audioContextInstance: AudioContext | null = null; + +const getAudioContext = (): AudioContext | null => { + if (typeof window === "undefined") return null; + return audioContextInstance; +}; + +const initializeAudioContext = (): void => { + if (typeof window === "undefined" || audioContextInstance) return; + + const AudioContextClass = + window.AudioContext || (window as any).webkitAudioContext; + if (!AudioContextClass) return; + + audioContextInstance = new AudioContextClass(); +}; + +const play = (frequency: number): void => { + const audioContext = getAudioContext(); + if (!audioContext) return; + + if (audioContext.state === "suspended") { + audioContext.resume(); + } + + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + const now = audioContext.currentTime; + + oscillator.type = `sine`; + oscillator.frequency.setValueAtTime(frequency, now); + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + gainNode.gain.setValueAtTime(0, now); + gainNode.gain.linearRampToValueAtTime(0.3, now + 0.01); + gainNode.gain.exponentialRampToValueAtTime(0.00001, now + 0.15); + + oscillator.start(now); + oscillator.stop(now + 0.15); +}; + +const NOTES = new Map([ + [`c0`, 16.35], + [`c#0`, 17.32], + [`d0`, 18.35], + [`d#0`, 19.45], + [`e0`, 20.6], + [`f0`, 21.83], + [`f#0`, 23.12], + [`g0`, 24.5], + [`g#0`, 25.96], + [`a0`, 27.5], + [`a#0`, 29.14], + [`b0`, 30.87], + + // Octave 1 + [`c1`, 32.7], + [`c#1`, 34.65], + [`d1`, 36.71], + [`d#1`, 38.89], + [`e1`, 41.2], + [`f1`, 43.65], + [`f#1`, 46.25], + [`g1`, 49.0], + [`g#1`, 51.91], + [`a1`, 55.0], + [`a#1`, 58.27], + [`b1`, 61.74], + + // Octave 2 + [`c2`, 65.41], + [`c#2`, 69.3], + [`d2`, 73.42], + [`d#2`, 77.78], + [`e2`, 82.41], + [`f2`, 87.31], + [`f#2`, 92.5], + [`g2`, 98.0], + [`g#2`, 103.83], + [`a2`, 110.0], + [`a#2`, 116.54], + [`b2`, 123.47], + + // Octave 3 + [`c3`, 130.81], + [`c#3`, 138.59], + [`d3`, 146.83], + [`d#3`, 155.56], + [`e3`, 164.81], + [`f3`, 174.61], + [`f#3`, 185.0], + [`g3`, 196.0], + [`g#3`, 207.65], + [`a3`, 220.0], + [`a#3`, 233.08], + [`b3`, 246.94], + + // Octave 4 + [`c4`, 261.63], + [`c#4`, 277.18], + [`d4`, 293.66], + [`d#4`, 311.13], + [`e4`, 329.63], + [`f4`, 349.23], + [`f#4`, 369.99], + [`g4`, 392.0], + [`g#4`, 415.3], + [`a4`, 440.0], + [`a#4`, 466.16], + [`b4`, 493.88], + + // Octave 5 + [`c5`, 523.25], + [`c#5`, 554.37], + [`d5`, 587.33], + [`d#5`, 622.25], + [`e5`, 659.25], + [`f5`, 698.46], + [`f#5`, 739.99], + [`g5`, 783.99], + [`g#5`, 830.61], + [`a5`, 880.0], + [`a#5`, 932.33], + [`b5`, 987.77], + + // Octave 6 + [`c6`, 1046.5], + [`c#6`, 1108.73], + [`d6`, 1174.66], + [`d#6`, 1244.51], + [`e6`, 1318.51], + [`f6`, 1396.91], + [`f#6`, 1479.98], + [`g6`, 1567.98], + [`g#6`, 1661.22], + [`a6`, 1760.0], + [`a#6`, 1864.66], + [`b6`, 1975.53], +] as const); + +initializeAudioContext(); + +const playNote = (noteName: Parameters[0]): void => { + const frequency = NOTES.get(noteName); + + if (!frequency) return; + + play(frequency); +}; + +export { playNote }; diff --git a/src/features/user-profile-preview/acts/add-user-profile-comment.act.ts b/src/features/user-profile-preview/acts/add-user-profile-comment.act.ts index a4113d9fb..898914f61 100644 --- a/src/features/user-profile-preview/acts/add-user-profile-comment.act.ts +++ b/src/features/user-profile-preview/acts/add-user-profile-comment.act.ts @@ -1,14 +1,15 @@ import { getAPI, parseError } from "api-4markdown"; import { AsyncResult } from "development-kit/utility-types"; import { getProfileId } from "../utils/get-profile-id"; -import { AddUserProfileCommentFormValues } from "../models"; import { setUserProfileStatsAction } from "../models/actions"; import { safeUserProfileStatsSelector } from "../models/selectors"; import { useUserProfileState } from "../store"; const addUserProfileCommentAct = async ({ content, -}: AddUserProfileCommentFormValues): AsyncResult => { +}: { + content: string; +}): AsyncResult => { try { const addedComment = await getAPI().call("addUserProfileComment")({ receiverProfileId: getProfileId(), @@ -19,7 +20,7 @@ const addUserProfileCommentAct = async ({ setUserProfileStatsAction({ ...stats, - comments: [...stats.comments, addedComment], + comments: [addedComment, ...stats.comments], }); return { is: `ok` }; diff --git a/src/features/user-profile-preview/acts/add-user-profile-score.act.ts b/src/features/user-profile-preview/acts/add-user-profile-score.act.ts new file mode 100644 index 000000000..c2e48e9a2 --- /dev/null +++ b/src/features/user-profile-preview/acts/add-user-profile-score.act.ts @@ -0,0 +1,35 @@ +import { customError, getAPI } from "api-4markdown"; +import { API4MarkdownPayload } from "api-4markdown-contracts"; +import { useUserProfileState } from "../store"; + +const addUserProfileScoreAct = async ( + payload: API4MarkdownPayload<"addUserProfileScore">, +): Promise => { + try { + const newScore = await getAPI().call("addUserProfileScore")(payload); + + const currentState = useUserProfileState.get(); + + if (currentState.stats.is !== `ok`) { + throw customError( + `User profile is not loaded yet but you are trying to add a score`, + ); + } + + useUserProfileState.swap({ + ...currentState, + stats: { + ...currentState.stats, + profile: { + ...currentState.stats.profile, + ...newScore, + }, + }, + }); + } catch (error) { + // biome-ignore lint/complexity/noUselessCatch: we need to throw the error to be handled by the caller + throw error; + } +}; + +export { addUserProfileScoreAct }; diff --git a/src/features/user-profile-preview/acts/rate-user-profile-comment.act.ts b/src/features/user-profile-preview/acts/rate-user-profile-comment.act.ts new file mode 100644 index 000000000..87d57c666 --- /dev/null +++ b/src/features/user-profile-preview/acts/rate-user-profile-comment.act.ts @@ -0,0 +1,69 @@ +import { customError, getAPI } from "api-4markdown"; +import { API4MarkdownPayload } from "api-4markdown-contracts"; +import { useUserProfileState } from "../store"; + +const rateUserProfileCommentAct = async ( + payload: API4MarkdownPayload<"rateUserProfileComment">, +): Promise => { + try { + const currentState = useUserProfileState.get(); + + if (currentState.stats.is !== `ok`) { + throw customError( + `User profile is not loaded yet but you are trying to add a score`, + ); + } + + const currentRating = + currentState.stats.comments.find( + (comment) => comment.id === payload.commentId, + )?.[payload.category] ?? 0; + + useUserProfileState.swap({ + ...currentState, + stats: { + ...currentState.stats, + comments: currentState.stats.comments.map((comment) => + comment.id === payload.commentId + ? { ...comment, [payload.category]: currentRating + 1 } + : comment, + ), + }, + }); + + await getAPI().call("rateUserProfileComment")(payload); + } catch (error) { + const currentState = useUserProfileState.get(); + + if (currentState.stats.is !== `ok`) { + throw customError( + `User profile is not loaded yet but you are trying to rate a comment`, + ); + } + + const currentRating = + currentState.stats.comments.find( + (comment) => comment.id === payload.commentId, + )?.[payload.category] ?? 0; + + useUserProfileState.swap({ + ...currentState, + stats: { + ...currentState.stats, + comments: currentState.stats.comments.map((comment) => + comment.id === payload.commentId + ? { + ...comment, + [payload.category]: + currentRating - 1 < 0 ? 0 : currentRating - 1, + } + : comment, + ), + }, + }); + + throw error; + } +}; + +export { rateUserProfileCommentAct }; diff --git a/src/features/user-profile-preview/acts/rate-user-profile.act.ts b/src/features/user-profile-preview/acts/rate-user-profile.act.ts new file mode 100644 index 000000000..bba399b55 --- /dev/null +++ b/src/features/user-profile-preview/acts/rate-user-profile.act.ts @@ -0,0 +1,57 @@ +import { customError, getAPI } from "api-4markdown"; +import { API4MarkdownPayload } from "api-4markdown-contracts"; +import { useUserProfileState } from "../store"; + +const rateUserProfileAct = async ( + payload: API4MarkdownPayload<"rateUserProfile">, +): Promise => { + try { + const currentState = useUserProfileState.get(); + + if (currentState.stats.is !== `ok`) { + throw customError( + `User profile is not loaded yet but you are trying to add a score`, + ); + } + + const currentRating = currentState.stats.profile[payload.category] ?? 0; + + useUserProfileState.swap({ + ...currentState, + stats: { + ...currentState.stats, + profile: { + ...currentState.stats.profile, + [payload.category]: currentRating + 1, + }, + }, + }); + + await getAPI().call("rateUserProfile")(payload); + } catch (error) { + const currentState = useUserProfileState.get(); + + if (currentState.stats.is !== `ok`) { + throw customError( + `User profile is not loaded yet but you are trying to add a score`, + ); + } + + const currentRating = currentState.stats.profile[payload.category] ?? 0; + + useUserProfileState.swap({ + ...currentState, + stats: { + ...currentState.stats, + profile: { + ...currentState.stats.profile, + [payload.category]: currentRating - 1 < 0 ? 0 : currentRating - 1, + }, + }, + }); + + throw error; + } +}; + +export { rateUserProfileAct }; diff --git a/src/features/user-profile-preview/containers/add-comment-widget.container.tsx b/src/features/user-profile-preview/containers/add-comment-widget.container.tsx index db32fa2bb..628b7bb36 100644 --- a/src/features/user-profile-preview/containers/add-comment-widget.container.tsx +++ b/src/features/user-profile-preview/containers/add-comment-widget.container.tsx @@ -2,10 +2,9 @@ import { Button } from "design-system/button"; import { Field } from "design-system/field"; import { Modal2 } from "design-system/modal2"; import { Textarea } from "design-system/textarea"; -import { maxLength, minLength } from "development-kit/form"; +import { ValidatorFn, ValidatorsSetup } from "development-kit/form"; import { useForm } from "development-kit/use-form"; import React from "react"; -import { AddUserProfileCommentFormValues } from "../models"; import { BiErrorAlt, BiInfoCircle } from "react-icons/bi"; import { Transaction } from "development-kit/utility-types"; import { addUserProfileCommentAct } from "../acts/add-user-profile-comment.act"; @@ -24,6 +23,27 @@ const limits = { }, } as const; +const commentContentValidator: ValidatorFn = (value) => { + const trimmed = value.trim(); + if (trimmed.length < limits.content.min) { + return `Comment must be at least ${limits.content.min} characters long`; + } + + if (trimmed.length > limits.content.max) { + return `Comment must be at most ${limits.content.max} characters long`; + } + + return null; +}; + +type FormValues = { + content: string; +}; + +const validators: ValidatorsSetup = { + content: [commentContentValidator], +}; + const AddCommentWidgetContainer = ({ onClose, }: AddCommentWidgetContainerProps) => { @@ -32,27 +52,27 @@ const AddCommentWidgetContainer = ({ }); const yourUserProfile = useYourUserProfileState(); - const [{ invalid, values, untouched }, { inject }] = - useForm( + const [{ invalid, values, result, untouched }, { inject }] = + useForm( { content: "", }, - { - content: [minLength(limits.content.min), maxLength(limits.content.max)], - }, + validators, ); const confirmAdd = async () => { setAddTransaction({ is: "busy" }); - const result = await addUserProfileCommentAct(values); + const addResult = await addUserProfileCommentAct({ + content: values.content, + }); - if (result.is === `ok`) { + if (addResult.is === `ok`) { onClose(); return; } - setAddTransaction(result); + setAddTransaction(addResult); }; const goToUserProfileForm = () => { @@ -84,6 +104,15 @@ const AddCommentWidgetContainer = ({ ? `Comment*` : `Comment (${values.content.length}/${limits.content.max})*` } + hint={ + result.content ? ( + {result.content} + ) : ( + + {limits.content.min}-{limits.content.max} characters + + ) + } >