Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ecb7e4f
feat: rework editing location
jona159 May 8, 2026
83d15c9
feat: centralize location logic
jona159 May 11, 2026
7217400
feat: improve edit location component, translations
jona159 May 11, 2026
3b9147f
fix: tw warning
jona159 May 11, 2026
c16472b
feat: mv general device schema
jona159 May 11, 2026
693f534
fix: tw warnings
jona159 May 11, 2026
43c2b0e
feat: return updated profile
jona159 May 11, 2026
c49b007
feat: autosave hook
jona159 May 12, 2026
621f7e1
fix: german translations
jona159 May 12, 2026
2742264
fix: avoid reprocessing old fetcher.data for unrelated state changes
jona159 May 12, 2026
f62d7e1
Merge branch 'feat/autosave-user-settings' into feat/autosave-device-…
jona159 May 12, 2026
0c6df5f
feat: translations
jona159 May 15, 2026
d2edd1e
feat: show save status
jona159 May 18, 2026
7f9dde8
Merge branch 'dev' into feat/autosave-user-settings
jona159 May 21, 2026
3186b5c
Merge branch 'feat/autosave-device-settings' into feat/autosave-user-…
jona159 May 21, 2026
070188d
feat: use autosave hook to save device location
jona159 May 21, 2026
43cf36c
feat: autosave general device fields
jona159 May 21, 2026
9538613
feat: add shared device-enum helper for use in client-side code
jona159 May 28, 2026
7ca80ac
fix: exposure
jona159 May 28, 2026
bada319
fix: reuse debounce constant
jona159 May 28, 2026
17af372
fix: types
jona159 May 28, 2026
5e3091c
feat: reusable status text component
jona159 May 28, 2026
9439056
feat: move password change functionality to account component
jona159 May 28, 2026
25c94b7
feat: require entering password when changing email
jona159 May 28, 2026
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
58 changes: 58 additions & 0 deletions app/components/autosave-status.text.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useTranslation } from 'react-i18next'
import { type AutosaveStatus } from '~/hooks/use-autosave-fetcher'

type AutosaveStatusTextProps = {
status: AutosaveStatus
hasValidationErrors?: boolean
namespace?: string
className?: string
}

export function AutosaveStatusText({
status,
hasValidationErrors = false,
namespace,
className = 'mt-2 min-h-5 text-sm',
}: AutosaveStatusTextProps) {
const { t } = useTranslation(namespace)

const shouldHideBecauseInvalid =
hasValidationErrors && (status === 'dirty' || status === 'saved')

if (shouldHideBecauseInvalid || status === 'idle') {
return <div className={className} aria-live="polite" />
}

const contentByStatus: Record<
Exclude<AutosaveStatus, 'idle'>,
{
label: string
className: string
}
> = {
saving: {
label: t('saving'),
className: 'text-gray-500',
},
error: {
label: t('autosave_failed'),
className: 'text-red-600',
},
dirty: {
label: t('unsaved_changes'),
className: 'text-gray-500',
},
saved: {
label: t('saved'),
className: 'text-green-500',
},
}

const content = contentByStatus[status]

return (
<div className={className} aria-live="polite">
<p className={content.className}>{content.label}</p>
</div>
)
}
4 changes: 2 additions & 2 deletions app/components/device/new/general-info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export function GeneralInfoStep() {
- Temperature

[${t('project_website')}](https://example.com)`}
className="min-h-[220px] w-full rounded-md border p-3 font-mono text-sm"
className="min-h-55 w-full rounded-md border p-3 font-mono text-sm"
/>
<div className="text-muted-foreground text-sm">
{description.length} / 5000
Expand All @@ -123,7 +123,7 @@ export function GeneralInfoStep() {

<div className="space-y-2">
<Label>{t('preview')}</Label>
<div className="min-h-[220px] rounded-md border p-3">
<div className="min-h-55 rounded-md border p-3">
{description.trim() ? (
<MarkdownContent>{description}</MarkdownContent>
) : (
Expand Down
41 changes: 27 additions & 14 deletions app/components/device/new/location-info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ import {
import { Input } from '@/components/ui/input'
import { Label } from '~/components/ui/label'
import { BaseMap } from '~/components/base-map'
import { LOCATION_LIMITS, isValidLocation } from '~/lib/location'

export function LocationStep() {
const mapRef = useRef<MapRef | null>(null)
const { register, setValue, watch } = useFormContext()
const {
register,
setValue,
watch,
formState: { errors },
} = useFormContext()
const { t } = useTranslation('newdevice')
const savedLatitude = watch('latitude')
const savedLongitude = watch('longitude')
Expand Down Expand Up @@ -104,7 +110,10 @@ export function LocationStep() {
}}
onClick={onMapClick}
>
{marker.latitude && marker.longitude && (
{isValidLocation({
latitude: Number(marker.latitude),
longitude: Number(marker.longitude),
}) && (
<Marker
latitude={Number(marker.latitude)}
longitude={Number(marker.longitude)}
Expand All @@ -129,17 +138,19 @@ export function LocationStep() {
id="latitude"
type="number"
step="any"
{...register('latitude', {
valueAsNumber: true,
required: 'Latitude is required',
min: -90,
max: 90,
})}
min={LOCATION_LIMITS.latitude.min}
max={LOCATION_LIMITS.latitude.max}
{...register('latitude')}
value={marker.latitude === '' ? '' : String(marker.latitude)}
onChange={handleLatitudeChange}
placeholder={t('enter latitude')}
className="w-full rounded-md border p-2"
/>
{errors.latitude?.message ? (
<p className="mt-1 text-sm text-red-600">
{String(errors.latitude.message)}
</p>
) : null}
</div>

<div>
Expand All @@ -148,17 +159,19 @@ export function LocationStep() {
id="longitude"
type="number"
step="any"
{...register('longitude', {
valueAsNumber: true,
required: 'Longitude is required',
min: -180,
max: 180,
})}
min={LOCATION_LIMITS.longitude.min}
max={LOCATION_LIMITS.longitude.max}
{...register('longitude')}
value={marker.longitude === '' ? '' : String(marker.longitude)}
onChange={handleLongitudeChange}
placeholder={t('enter longitude')}
className="w-full rounded-md border p-2"
/>
{errors.longitude?.message ? (
<p className="mt-1 text-sm text-red-600">
{String(errors.longitude.message)}
</p>
) : null}
</div>
</div>
</div>
Expand Down
58 changes: 2 additions & 56 deletions app/components/device/new/new-device-stepper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,60 +29,8 @@ import {
import { useToast } from '~/components/ui/use-toast'
import { DeviceModelEnum } from '~/db/schema/enum'
import { type loader } from '~/routes/device.new'

const generalInfoSchema = z.object({
name: z
.string()
.min(2, 'Name must be at least 2 characters')
.min(1, 'Name is required'),
description: z
.string()
.max(5000, 'Description should not exceed 5000 characters')
.optional()
.nullable(),
exposure: z.enum(['indoor', 'outdoor', 'mobile', 'unknown'], {
error: () => 'Exposure is required',
}),
temporaryExpirationDate: z
.string()
.optional()
.transform((date) => (date ? new Date(date) : undefined)) // Transform string to Date
.refine(
(date) =>
!date || date <= new Date(Date.now() + 31 * 24 * 60 * 60 * 1000),
{
message: 'Temporary expiration date must be within 1 month from now',
},
),
tags: z
.array(
z.object({
value: z.string(),
}),
)
.optional(),
})

const locationSchema = z.object({
latitude: z.coerce
.number({
error: (issue) =>
issue.input === undefined
? 'Latitude is required'
: 'Latitude must be a valid number',
})
.min(-90, 'Latitude must be greater than or equal to -90')
.max(90, 'Latitude must be less than or equal to 90'),
longitude: z.coerce
.number({
error: (issue) =>
issue.input === undefined
? 'Longitude is required'
: 'Longitude must be a valid number',
})
.min(-180, 'Longitude must be greater than or equal to -180')
.max(180, 'Longitude must be less than or equal to 180'),
})
import { locationSchema, type LocationData } from '~/lib/location'
import { generalInfoSchema, type GeneralInfoData } from '~/lib/device-general'

const deviceSchema = z.object({
model: z.enum(DeviceModelEnum.enumValues, {
Expand Down Expand Up @@ -152,8 +100,6 @@ export const Stepper = defineStepper(
},
)

type GeneralInfoData = z.infer<typeof generalInfoSchema>
type LocationData = z.infer<typeof locationSchema>
type DeviceData = z.infer<typeof deviceSchema>
type SensorData = z.infer<typeof sensorsSchema>
type AdvancedData = z.infer<typeof advancedSchema>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { Label } from '~/components/ui/label'
import { ToggleGroup, ToggleGroupItem } from '~/components/ui/toggle-group'
import {
type DeviceExposureType,
type DeviceStatusType,
} from '~/db/schema/enum'

import { useTranslation } from 'react-i18next'
import { DeviceExposureType, DeviceStatusType } from '~/lib/device-enums'

interface FilterOptionsProps {
exposure: DeviceExposureType[]
Expand Down
5 changes: 1 addition & 4 deletions app/components/header/nav-bar/filter-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,13 @@ import {
import { NavbarContext } from '.'
import Spinner from '~/components/spinner'
import { Button } from '~/components/ui/button'
import {
type DeviceExposureType,
type DeviceStatusType,
} from '~/db/schema/enum'
import { type loader as exploreLoader } from '~/routes/explore'
import FilterOptions from './filter-options/filter-options'
import FilterTags from './filter-options/filter-tags'
import FilterPhenomena from './filter-options/filter-phenomena'
import FilterTime from './filter-options/filter-time'
import { useTranslation } from 'react-i18next'
import { DeviceExposureType, DeviceStatusType } from '~/lib/device-enums'

export type TimeFilterState =
| {
Expand Down
2 changes: 1 addition & 1 deletion app/components/map/filter-visualization.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { X } from 'lucide-react'
import { Fragment, useEffect } from 'react'
import { useLoaderData, useNavigate } from 'react-router'
import { DeviceExposureZodEnum, DeviceStatusZodEnum } from '~/db/schema/enum'
import { DeviceExposureZodEnum, DeviceStatusZodEnum } from '~/lib/device-enums'
import { type loader } from '~/routes/explore'

const FILTER_KEYS = new Set(['exposure', 'status', 'tags'])
Expand Down
21 changes: 11 additions & 10 deletions app/db/models/profile.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,18 @@ export async function getProfileByUsername(username: string) {
export async function updateProfile(
id: Profile['id'],
displayName: Profile['displayName'],
visibility: Profile['public'],
visibility: boolean,
) {
try {
const result = await drizzleClient
.update(profile)
.set({ displayName, public: visibility })
.where(eq(profile.id, id))
return result
} catch (error) {
throw error
}
const [updatedProfile] = await drizzleClient
.update(profile)
.set({
displayName,
public: visibility,
})
.where(eq(profile.id, id))
.returning()

return updatedProfile
}

export async function createProfile(
Expand Down
27 changes: 6 additions & 21 deletions app/db/schema/enum.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,13 @@
import { pgEnum } from 'drizzle-orm/pg-core'
import { z } from 'zod'

// Enum for device exposure types
export const DeviceExposureEnum = pgEnum('exposure', [
'indoor',
'outdoor',
'mobile',
'unknown',
])

// Zod schema for validating device exposure types
export const DeviceExposureZodEnum = z.enum(DeviceExposureEnum.enumValues)

// Type inferred from the Zod schema for device exposure types
export type DeviceExposureType = z.infer<typeof DeviceExposureZodEnum>

// Enum for device status types
export const DeviceStatusEnum = pgEnum('status', ['active', 'inactive', 'old'])
import {
DEVICE_EXPOSURE_VALUES,
DEVICE_STATUS_VALUES,
} from '~/lib/device-enums'

// Zod schema for validating device status types
export const DeviceStatusZodEnum = z.enum(DeviceStatusEnum.enumValues)
export const DeviceExposureEnum = pgEnum('exposure', DEVICE_EXPOSURE_VALUES)

// Type inferred from the Zod schema for device status types
export type DeviceStatusType = z.infer<typeof DeviceStatusZodEnum>
export const DeviceStatusEnum = pgEnum('status', DEVICE_STATUS_VALUES)

// Enum for device model types
export const DeviceModelEnum = pgEnum('model', [
Expand Down
Loading
Loading