Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"dependencies": {
"@elysiajs/cors": "^1.2.0",
"@workspace/schema": "workspace:*",
"elysia": "^1.2.12",
"puppeteer-core": "^24.2.0"
"elysia": "^1.2.25",
"puppeteer-core": "^24.4.0"
},
"devDependencies": {
"@tsconfig/bun": "^1.0.7",
Expand Down
19 changes: 9 additions & 10 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"messages:compile": "lingui compile --typescript",
"preview": "vite preview"
},
"packageManager": "bun@1.2.2",
"packageManager": "bun@1.2.4",
"dependencies": {
"@elysiajs/eden": "^1.2.0",
"@lingui/core": "^5.2.0",
Expand All @@ -22,10 +22,10 @@
"browser-fs-access": "^0.35.0",
"clsx": "^2.1.1",
"idb-keyval": "^6.2.1",
"jotai": "^2.12.0",
"jotai": "^2.12.1",
"jotai-effect": "^2.0.1",
"jotai-optics": "^0.4.0",
"lucide-react": "^0.475.0",
"lucide-react": "^0.479.0",
"optics-ts": "^2.4.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
Expand All @@ -37,20 +37,19 @@
"@lingui/conf": "^5.2.0",
"@lingui/swc-plugin": "5.1.0",
"@lingui/vite-plugin": "^5.2.0",
"@tailwindcss/postcss": "^4.0.6",
"@tailwindcss/vite": "^4.0.6",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@tsconfig/vite-react": "^3.4.0",
"@types/bun": "^1.2.2",
"@types/node": "^22.13.1",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/bun": "^1.2.4",
"@types/node": "^22.13.10",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react-swc": "3.8.0",
"@workspace/eslint-config": "workspace:*",
"elysia": "^1.2.12",
"happy-dom": "^17.0.4",
"elysia": "^1.2.25",
"happy-dom": "^17.4.2",
"tailwindcss": "^4.0.6",
"vite-tsconfig-paths": "^5.1.4"
}
Expand Down
8 changes: 0 additions & 8 deletions apps/web/postcss.config.mjs

This file was deleted.

10 changes: 4 additions & 6 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Suspense } from 'react';
import { LoaderCircle } from 'lucide-react';
import { Loader2 } from 'lucide-react';
import { Separator } from '@workspace/ui/components/separator';
import { AppProvider } from '@/components/AppProvider';
import { ChangeTheme } from '@/components/ChangeTheme';
Expand Down Expand Up @@ -37,12 +37,10 @@ export function App() {
<ChangeTheme className="justify-self-end" variant="outline" />
</header>

<main className="relative grid place-items-center overflow-auto">
<Suspense
fallback={<LoaderCircle className="size-12 animate-spin" />}
>
<main className="grid place-items-center p-5 md:p-16">
<Suspense fallback={<Loader2 className="size-12 animate-spin" />}>
<Graphics
className="h-full w-full p-16 drop-shadow-xl md:drop-shadow-2xl"
className="grid h-full place-items-center"
fallback={<NoImages />}
/>
</Suspense>
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/atoms/importAtoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,14 @@ export const importAtom = atomWithExpiringWriteState(
: [imageSource];

const blobs = (
await Promise.all(
imageSources.map<Promise<Blob | Blob[]>>(async source => {
await Promise.all<Blob[]>(
imageSources.map(async source => {
if (source instanceof File) {
return source;
return [source];
}
if (typeof source === 'string') {
return (await fetch(source, { signal })).blob();
const response = await fetch(source, { signal });
return [await response.blob()];
}
const { url, deviceType } = source;
const { data, error } = await client
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/atoms/waveAtom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { amplitudeAtom, wavelengthAtom } from '@/atoms/waveformAtoms';
import { sin, heartbeat } from '@/utils/waveFunctions';
import { WaveFunction } from '@/types';

/**
* Rounds a number to two decimal places.
*/
function round(num: number) {
return Math.round((num + Number.EPSILON) * 100) / 100;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/AtomSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Shortcut, ShortcutProps } from '@/components/Shortcut/Shortcut';
export interface AtomSliderProps
extends Omit<React.ComponentProps<typeof Slider>, 'value' | 'onValueChange'> {
/**
* The Jotai atom that stores and updates the slider value.
* The Jotai atom that reads and writes the slider value.
*/
atom: PrimitiveAtom<number>;

Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/CopyImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import { Button } from '@workspace/ui/components/button';
import { Tooltip } from '@workspace/ui/components/tooltip';
import { graphicsAtom } from '@/atoms/graphicsAtom';
import { isClipboardSupported, useClipboard } from '@/hooks/useClipboard';
import { LOADABLE_STATE, MIME_TYPES } from '@/constants';
import { rasterize } from '@/utils/rasterize';
import { LoadableIcon } from './LoadableIcon';
import { LoadableIcon } from '@/components/LoadableIcon';
import { LOADABLE_STATE, MIME_TYPES } from '@/constants';

export type CopyImageProps = Omit<
React.ComponentProps<typeof Button>,
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/components/Glow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { cn } from '@workspace/ui/lib/utils';

export type GlowProps = React.ComponentProps<'div'>;

export function Glow(props: GlowProps) {
const { children, className, ...other } = props;
return (
<div
className={cn(
'relative',
'before:animate-glow before:absolute before:top-[40%] before:left-0 before:h-full before:w-full',
'before:bg-gradient-to-b',
'before:[--tw-gradient-stops:var(--color-ring),var(--color-ring),transparent_40%]',
'before:opacity-0',
'before:blur-[180px]',
className
)}
{...other}
>
{children}
</div>
);
}
109 changes: 62 additions & 47 deletions apps/web/src/components/Graphics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { rotationAtom } from '@/atoms/waveformAtoms';
import { largestImageAtom } from '@/atoms/largestImageAtom';
import { scaleAtom } from '@/atoms/scaleAtom';
import { graphicsAtom } from '@/atoms/graphicsAtom';
import { Glow } from '@/components/Glow';

declare module 'react' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand All @@ -15,16 +16,24 @@ declare module 'react' {
}
}

export interface GraphicsProps
extends Omit<React.ComponentPropsWithoutRef<'svg'>, 'viewBox'> {
export interface GraphicsProps extends React.ComponentProps<'div'> {
/**
* Alternative content to render when images are unavailable.
*/
fallback?: React.ReactNode;

/**
* Props to be passed to the `<svg>` element.
*/
svgProps?: Partial<Omit<React.ComponentProps<'svg'>, 'viewBox'>>;
}

export function Graphics(props: GraphicsProps) {
const { fallback, style, className, ...other } = props;
const {
fallback,
svgProps: { style: svgStyle, className: svgClassName, ...svgOther } = {},
...other
} = props;

const setGraphics = useSetAtom(graphicsAtom);
const orderedImages = useAtomValue(orderedImagesAtom);
Expand All @@ -43,51 +52,57 @@ export function Graphics(props: GraphicsProps) {
const rotationScale = 1 + Math.abs(Math.sin((rotation * Math.PI) / 180));

return (
<svg
ref={setGraphics}
viewBox={`0 0 ${width} ${height}`}
className={cn(
'transform-gpu will-change-transform',
'max-h-(--Graphics-height) max-w-(--Graphics-width)',
className
)}
style={
{
'--Graphics-width': `${width}px`,
'--Graphics-height': `${height}px`,
...style
} as React.CSSProperties
}
{...other}
>
<defs>
<path id={waveId} d={wave} />
{orderedImages.slice(0, -1).map((image, i) => (
<clipPath
key={image.id}
id={waveId + (i + 1)}
transform={`rotate(${rotation})`}
transformOrigin="50% 50%"
>
<use
href={`#${waveId}`}
transform={`translate(0, ${((i + 1) * sectionHeight - height / 2) * rotationScale}) scale(${scale})`}
<Glow {...other}>
<svg
ref={setGraphics}
viewBox={`0 0 ${width} ${height}`}
className={cn(
'h-full w-full',
'transform-gpu will-change-transform',
'max-h-(--Graphics-height) max-w-(--Graphics-width)',
svgClassName
)}
preserveAspectRatio=""
style={
{
'--Graphics-width': `${width}px`,
'--Graphics-height': `${height}px`,
...svgStyle
} as React.CSSProperties
}
{...svgOther}
>
<defs>
<path id={waveId} d={wave} />
{orderedImages.slice(0, -1).map((image, i) => (
<clipPath
key={image.id}
id={waveId + (i + 1)}
transform={`rotate(${rotation})`}
transformOrigin="50% 50%"
>
<use
href={`#${waveId}`}
transform={`translate(0, ${((i + 1) * sectionHeight - height / 2) * rotationScale}) scale(${scale})`}
transformOrigin="50% 50%"
/>
</clipPath>
))}
</defs>
<g>
{orderedImages.map((image, i) => (
<image
key={image.id}
href={image.src}
clipPath={
i >= 1
? `url(#${waveId}${orderedImages.length - i})`
: undefined
}
/>
</clipPath>
))}
</defs>
<g>
{orderedImages.map((image, i) => (
<image
key={image.id}
href={image.src}
clipPath={
i >= 1 ? `url(#${waveId}${orderedImages.length - i})` : undefined
}
/>
))}
</g>
</svg>
))}
</g>
</svg>
</Glow>
);
}
29 changes: 29 additions & 0 deletions apps/web/src/components/__tests__/LoadableIcon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { CheckCheck, X } from 'lucide-react';
import { render, screen } from '@testing-library/react';
import { LoadableIcon } from '@/components/LoadableIcon';
import { LoadableState } from '@/types';

describe('LoadableIcon', () => {
it.each<[LoadableState | null | undefined, string]>([
['loading', 'lucide-loader-circle'],
['hasData', 'lucide-check'],
['hasError', 'lucide-ban'],
[null, 'lucide-x'],
[undefined, 'lucide-x']
])('renders the correct icon for state: %s', (state, expectedIcon) => {
render(<LoadableIcon state={state} fallback={X} role="image" />);
expect(screen.getByRole('image')).toHaveClass(expectedIcon);
});

it('uses a custom icon mapping if provided', () => {
render(
<LoadableIcon
state="hasData"
fallback={X}
iconMapping={{ hasData: CheckCheck }}
role="image"
/>
);
expect(screen.getByRole('image')).toHaveClass('lucide-check-check');
});
});
8 changes: 6 additions & 2 deletions apps/web/src/hooks/useClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import { LoadableState } from '@/types';
import { DEFAULT_LOADABLE_STATE_TIMEOUT, LOADABLE_STATE } from '@/constants';
import { useExpireAtom } from '@/hooks/useExpireAtom';

type Copy = (
...args: ConstructorParameters<typeof ClipboardItem>
) => Promise<void>;

export const isClipboardSupported =
navigator.clipboard && typeof window.ClipboardItem === 'function';

export interface UseClipboardResult {
copy: (...args: ConstructorParameters<typeof ClipboardItem>) => Promise<void>;
copy: Copy;
state: LoadableState;
}

Expand All @@ -18,7 +22,7 @@ export interface UseClipboardOptions {
export function useClipboard(options: UseClipboardOptions = {}) {
const { timeout = DEFAULT_LOADABLE_STATE_TIMEOUT } = options;
const [state, setState] = useExpireAtom<LoadableState | null>(null);
const copy = useCallback<UseClipboardResult['copy']>(
const copy = useCallback<Copy>(
async (...args) => {
setState(LOADABLE_STATE.LOADING);
try {
Expand Down
Loading