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

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions web-app/src/components/AppFooter.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { buttonVariants } from './ui/button'
import { CoffeeIcon, GithubIcon, GlobeIcon, SettingsIcon } from 'lucide-react'
import { useTranslation } from '@/i18n'
import { AppVersion } from './AppVersionPayload'
import { VERSION_DISPLAY } from '@/lib/version'
import { Separator } from './ui/separator'
import { Link } from 'react-router-dom'
import { handleExternalLinkClick } from '@/lib/openExternalUrl'
Expand Down Expand Up @@ -29,7 +29,9 @@ export function AppFooter() {
return (
<div className="w-full h-10 items-center justify-between bottom-0 flex px-4 bg-background/50 border-t border-border backdrop-blur-md py-4">
<div className="space-x-2 flex-1 w-full flex items-center relative">
<AppVersion />
<span className="text-sm text-muted-foreground ml-1">
{VERSION_DISPLAY}
</span>
<Separator className="h-6" orientation="vertical" />

{CONTACTS.map((contact) => (
Expand Down
3 changes: 2 additions & 1 deletion web-app/src/components/common/PulseAnimation.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Lottie from 'lottie-react'
import pulseAnimationOriginal from '../../assets/pulse.json'
import { useMemo } from 'react'
import { cn } from '@/lib/utils'

interface PulseAnimationProps {
isTransporting: boolean
Expand Down Expand Up @@ -50,7 +51,7 @@ export function PulseAnimation({
}, [isTransporting, hasActiveConnections])

return (
<div className={className}>
<div className={cn(className, isTransporting && 'max-sm:hidden')}>
<Lottie
animationData={animationData}
loop={true}
Expand Down
140 changes: 133 additions & 7 deletions web-app/src/components/common/TransferProgressBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,153 @@ interface TransferProgressBarProps {
export function formatSpeed(speedBps: number): string {
const mbps = speedBps / (1024 * 1024)
const kbps = speedBps / 1024

if (mbps >= 1) {
return `${mbps.toFixed(2)} MB/s`
} else {
return `${kbps.toFixed(2)} KB/s`
}
}

// ─── Circular segmented ring (mobile only) ───────────────────────────────────
//
// 30 thin arc segments arranged clockwise from the top (12 o'clock).
// Each segment occupies (360 / 30) = 12° with a small gap between them.
// Fill logic mirrors the horizontal bar: full segments + one partial segment.

const SEGMENT_COUNT = 30
const SEGMENT_KEYS = Array.from(
{ length: SEGMENT_COUNT },
(_, i) => `segment-${i}`
)
const SEGMENT_ANGLE = 360 / SEGMENT_COUNT // 12° per segment
const GAP_ANGLE = 2.2 // degrees of gap on each side
const ARC_ANGLE = SEGMENT_ANGLE - GAP_ANGLE * 2

const RING_SIZE = 200 // SVG viewBox size (px)
const CENTER = RING_SIZE / 2 // 100
const RADIUS = 84 // arc radius
const STROKE_WIDTH = 4.5 // thinner stroke for a sleeker look

/** Convert polar coordinates (angle from 12 o'clock, clockwise) to Cartesian. */
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
const angleRad = ((angleDeg - 90) * Math.PI) / 180
return {
x: cx + r * Math.cos(angleRad),
y: cy + r * Math.sin(angleRad),
}
}

/** Build an SVG arc path for a segment starting at `startAngle` spanning `sweep` degrees. */
function arcPath(startAngle: number, sweep: number): string {
const start = polarToCartesian(CENTER, CENTER, RADIUS, startAngle)
const end = polarToCartesian(CENTER, CENTER, RADIUS, startAngle + sweep)
const largeArc = sweep > 180 ? 1 : 0
return `M ${start.x} ${start.y} A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${end.x} ${end.y}`
}

interface CircularRingProps {
percentage: number
}

function CircularRing({ percentage }: CircularRingProps) {
const { t } = useTranslation()
const filledSegments = Math.floor((percentage / 100) * SEGMENT_COUNT)

// How far into the current (partial) segment we are, as a 0–1 fraction
const partialFraction = (percentage / 100) * SEGMENT_COUNT - filledSegments

return (
<svg
viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}
width={RING_SIZE}
height={RING_SIZE}
role="progressbar"
aria-label={t('common:transfer.progress')}
aria-valuenow={Math.round(percentage)}
aria-valuemin={0}
aria-valuemax={100}
className="mx-auto block"
>
{SEGMENT_KEYS.map((segmentKey, index) => {
const segmentStartAngle = index * SEGMENT_ANGLE + GAP_ANGLE

const isFilled = index < filledSegments
const isPartial = index === filledSegments && partialFraction > 0

// For the partial segment we shorten the visible arc proportionally
const visibleSweep = isFilled
? ARC_ANGLE
: isPartial
? ARC_ANGLE * partialFraction
: 0

return (
<g key={segmentKey}>
{/* Background (unfilled) arc */}
<path
d={arcPath(segmentStartAngle, ARC_ANGLE)}
fill="none"
stroke="var(--input)"
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
className="transition-all duration-300 ease-in-out"
/>
{/* Filled arc (rendered on top) */}
{visibleSweep > 0 && (
<path
d={arcPath(segmentStartAngle, visibleSweep)}
fill="none"
stroke="var(--app-primary)"
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
className="transition-all duration-300 ease-in-out"
/>
)}
</g>
)
})}
</svg>
)
}

// ─── Main component ───────────────────────────────────────────────────────────

export function TransferProgressBar({ progress }: TransferProgressBarProps) {
const { percentage } = progress
const barCount = 30
const { t } = useTranslation()

const filledBars = Math.floor((percentage / 100) * barCount)

return (
<div className="space-y-3">
<div className="space-y-2">
{/* ── Mobile layout: circular ring ── */}
<div className="sm:hidden flex flex-col items-center gap-3">
<div className="relative inline-flex items-center justify-center">
<CircularRing percentage={percentage} />

{/* Labels centred inside the ring */}
<div className="absolute inset-0 flex flex-col items-center justify-center gap-0.5 text-center pointer-events-none">
<span className="text-2xl font-normal leading-none tabular-nums">
{percentage.toFixed(1)}%
</span>
<span className="text-xs text-muted-foreground">
{formatSpeed(progress.speedBps)}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{(progress.bytesTransferred / (1024 * 1024)).toFixed(2)} /{' '}
{(progress.totalBytes / (1024 * 1024)).toFixed(2)} MB
</span>
{progress.etaSeconds !== undefined && (
<span className="text-xs text-muted-foreground">
{t('common:transfer.eta')}: {formatETA(progress.etaSeconds)}
</span>
)}
</div>
</div>
</div>

{/* ── Desktop layout: horizontal segment bars ── */}
<div className="hidden sm:block space-y-2">
<div className="flex items-center justify-between text-xs">
<span>{t('common:transfer.progress')}</span>
<span>{percentage.toFixed(1)}%</span>
Expand All @@ -52,10 +181,7 @@ export function TransferProgressBar({ progress }: TransferProgressBarProps) {
// biome-ignore lint/suspicious/noArrayIndexKey: The values are always static so it is okay
key={index}
className="relative flex-1 rounded-sm bg-input transition-all duration-300 ease-in-out"
style={{
minWidth: '3px',
height: '100%',
}}
style={{ minWidth: '3px', height: '100%' }}
>
<div
className="absolute bottom-0 left-0 right-0 rounded-sm transition-all duration-300 ease-in-out"
Expand Down
2 changes: 1 addition & 1 deletion web-app/src/components/common/TransferSuccessScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export function TransferSuccessScreen({
type="button"
variant="secondary"
onClick={onOpenFolder}
className="flex-1 "
className="flex-1 hidden sm:flex"
>
<ExternalLinkIcon size={12} />
{t('common:transfer.open')}
Expand Down
2 changes: 1 addition & 1 deletion web-app/src/components/layouts/RootLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function RootLayout() {
return (
<>
{!IS_ANDROID && <AppUpdater />}
<main className="h-screen flex flex-col relative glass-background select-none bg-background">
<main className="h-dvh min-h-screen flex flex-col relative glass-background select-none bg-background">
{IS_LINUX && !IS_ANDROID && <TitleBar title={t('appTitle')} />}

{IS_MACOS && (
Expand Down
4 changes: 2 additions & 2 deletions web-app/src/components/receiver/Receiver.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function Receiver({ onTransferStateChange }: ReceiverProps) {
}, [isReceiving, onTransferStateChange])

return (
<div className="p-6 space-y-6 relative h-112 overflow-y-auto flex flex-col">
<div className="p-2 sm:p-6 space-y-6 relative h-[62dvh] sm:h-112 overflow-y-auto flex flex-col">
{!isReceiving ? (
<>
<div className="text-center">
Expand All @@ -63,7 +63,7 @@ export function Receiver({ onTransferStateChange }: ReceiverProps) {
type="button"
variant="ghost"
onClick={() => setShowInstructionsDialog(true)}
className="absolute top-6 right-6"
className="absolute top-0 right-0 sm:top-6 sm:right-6"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider slight offset for the info button on mobile.

With the container padding reduced to p-2 on mobile and the button now at top-0 right-0, the info button sits right at the container's edge. This is functional, but you might find it feels a bit tight visually.

If it looks cramped during testing, a small offset like top-1 right-1 could provide a touch more breathing room while still being compact. That said, if it looks good on your test devices, feel free to keep it as-is!

>
<Info />
</Button>
Expand Down
6 changes: 4 additions & 2 deletions web-app/src/components/receiver/ReceivingActiveCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ export function ReceivingActiveCard({
/>
</div>

<p className="text-xs text-center">{t('common:receiver.keepAppOpen')}</p>
<p className="text-xs text-center my-10 sm:my-0">
{t('common:receiver.keepAppOpen')}
</p>

{isTransporting && transferProgress && (
<TransferProgressBar progress={transferProgress} />
Expand All @@ -53,7 +55,7 @@ export function ReceivingActiveCard({
size="icon-lg"
type="button"
onClick={onStopReceiving}
className="absolute top-0 right-6 rounded-full"
className="absolute top-0 right-2 sm:right-6 rounded-full font-medium transition-colors not-disabled:not-active:not-data-pressed:before:shadow-none dark:not-disabled:before:shadow-none dark:not-disabled:not-active:not-data-pressed:before:shadow-none"
aria-label="Stop receiving"
>
<Square className="w-4 h-4" fill="currentColor" />
Expand Down
78 changes: 42 additions & 36 deletions web-app/src/components/sender/BrowseButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,42 +12,48 @@ export function BrowseButtons({
const { t } = useTranslation()

return (
<Group className="mx-auto">
<Button
type="button"
onClick={(e) => {
e.stopPropagation()
onBrowseFile()
}}
disabled={isLoading}
>
{isLoading ? (
t('common:loading')
) : (
<>
{t('common:sender.browseFile')}
<FileTextIcon />
</>
)}
</Button>
<GroupSeparator />
<Button
type="button"
onClick={(e) => {
e.stopPropagation()
onBrowseFolder()
}}
disabled={isLoading}
>
{isLoading ? (
t('common:loading')
) : (
<>
{t('common:sender.browseFolder')}
<FolderOpenIcon />
</>
)}
</Button>
<Group className="mx-auto flex w-full max-w-sm flex-col gap-2 mt-2 sm:mt-0 sm:w-fit sm:max-w-none sm:flex-row sm:gap-0">
<div className="w-full sm:w-auto">
<Button
type="button"
onClick={(e) => {
e.stopPropagation()
onBrowseFile()
}}
disabled={isLoading}
className="w-full rounded-lg sm:rounded-l-lg sm:rounded-r-none text-sm px-3 py-2 sm:px-4 sm:py-2.5"
>
{isLoading ? (
t('common:loading')
) : (
<>
{t('common:sender.browseFile')}
<FileTextIcon />
</>
)}
</Button>
</div>
<GroupSeparator className="hidden sm:block" />
<div className="w-full sm:w-auto">
<Button
type="button"
onClick={(e) => {
e.stopPropagation()
onBrowseFolder()
}}
disabled={isLoading}
className="w-full rounded-lg sm:rounded-r-lg sm:rounded-l-none text-sm px-3 py-2 sm:px-4 sm:py-2.5"
>
{isLoading ? (
t('common:loading')
) : (
<>
{t('common:sender.browseFolder')}
<FolderOpenIcon />
</>
)}
</Button>
</div>
</Group>
)
}
11 changes: 7 additions & 4 deletions web-app/src/components/sender/Dropzone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,18 @@ export function Dropzone({
const getSubText = () => {
if (isLoading) return t('common:sender.pleaseWaitProcessing')
if (selectedPath) {
const fileName = selectedPath.split('/').pop() ?? ''
const displayName =
fileName.length > 60 ? `${fileName.slice(0, 60)}…` : fileName
return (
<div>
<div
className="font-medium cursor-pointer hover:opacity-80 transition-opacity flex items-center justify-center"
onClick={onToggleFullPath}
title="Click to toggle full path"
>
{selectedPath.split('/').pop()}
<span className="-mr-2">
{displayName}
<span className="-mr-2 hidden sm:block ">
{showFullPath ? (
<ChevronDown className="p-0.5 h-6 w-6" size={16} />
) : (
Expand All @@ -73,7 +76,7 @@ export function Dropzone({
</span>
</div>
<div
className="text-xs mt-1 opacity-75 break-all transition-opacity"
className="text-xs mt-1 opacity-75 break-all transition-opacity max-sm:hidden"
style={{
visibility: showFullPath ? 'visible' : 'hidden',
}}
Expand Down Expand Up @@ -144,7 +147,7 @@ export function Dropzone({
</div>

<div>
<p className="text-lg font-medium mb-2 text-accent-foreground">
<p className=" hidden sm:block text-lg font-medium mb-2 text-accent-foreground">
{getStatusText()}
</p>
Comment on lines +150 to 152
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor: Leading space in className.

There's a leading space before "hidden" in the className string.

🧹 Suggested fix
-				<p className=" hidden sm:block text-lg font-medium mb-2 text-accent-foreground">
+				<p className="hidden sm:block text-lg font-medium mb-2 text-accent-foreground">

<div className="text-sm text-muted-foreground">{getSubText()}</div>
Expand Down
Loading