Skip to content

Commit

Permalink
Manage persisted courses
Browse files Browse the repository at this point in the history
  • Loading branch information
OlliV committed Oct 29, 2023
1 parent 0abaec1 commit c09ece5
Show file tree
Hide file tree
Showing 6 changed files with 137 additions and 16 deletions.
38 changes: 32 additions & 6 deletions components/CourseList.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
Expand All @@ -6,9 +7,18 @@ import IconButton from '@mui/material/IconButton';
import IconDelete from '@mui/icons-material/Delete';
import AutoSizer, { Size } from 'react-virtualized-auto-sizer';
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { PersistedCourse, deleteCourse, getCourses } from '../lib/course_storage';

function renderRow(props: ListChildComponentProps) {
const { index, style } = props;
const { data, index, style } = props;
const course = data.courses[index];

const deleteThis = () => {
if (course) {
deleteCourse(course.id);
data.setLastDel(Date.now());
}
};

return (
<ListItem
Expand All @@ -17,19 +27,34 @@ function renderRow(props: ListChildComponentProps) {
component="div"
disablePadding
secondaryAction={
<IconButton edge="end" aria-label="delete">
<IconButton edge="end" aria-label="delete" onClick={deleteThis}>
<IconDelete />
</IconButton>
}
>
<ListItemButton>
<ListItemText primary={`Item ${index + 1}`} />
<ListItemButton
onClick={() => {
if (course) data.onSelectCourse(course);
}}
>
<ListItemText primary={course?.name || '<Load error>'} />
</ListItemButton>
</ListItem>
);
}

export default function CourseList({ height }: { height: string }) {
export default function CourseList({
height,
changeId,
onSelectCourse,
}: {
height: string;
changeId: number;
onSelectCourse: (persistedCourse: PersistedCourse) => void;
}) {
const [lastDel, setLastDel] = useState(0);
const courses = useMemo(getCourses, [lastDel, changeId]);

return (
<Box sx={{ width: '100%', height, maxWidth: 360, bgcolor: 'background.paper' }}>
<AutoSizer>
Expand All @@ -38,8 +63,9 @@ export default function CourseList({ height }: { height: string }) {
height={size.height}
width={size.width}
itemSize={46}
itemCount={200}
itemCount={courses.length}
overscanCount={5}
itemData={{ courses, setLastDel, onSelectCourse }}
>
{renderRow}
</FixedSizeList>
Expand Down
5 changes: 4 additions & 1 deletion components/CreateCourse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';

export default function CreateCourseDialog({ newCourse }: { newCourse: (name: string, file: File) => void }) {
const nameRef = useRef();
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const [open, setOpen] = useState(false);
const handleClickOpen = () => {
setOpen(true);
};
const handleCreate = () => {
newCourse('', uploadInputRef.current?.files[0] || null);
// @ts-ignore
newCourse(nameRef?.current?.value || '', uploadInputRef.current?.files[0] || null);
setOpen(false);
};
const handleCancel = () => {
Expand All @@ -42,6 +44,7 @@ export default function CreateCourseDialog({ newCourse }: { newCourse: (name: st
label="Course Name"
fullWidth
variant="standard"
inputRef={nameRef}
/>
<InputLabel htmlFor="import-file" hidden>
<input ref={uploadInputRef} id="import-file" name="import-file" type="file" />
Expand Down
9 changes: 8 additions & 1 deletion lib/ab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ export function arrayBufferToBase64(buf: ArrayBuffer): string {
if (typeof window === 'undefined') {
return Buffer.from(buf).toString('base64');
} else {
return window.btoa(String.fromCharCode.apply(null, new Uint8Array(buf)));
// "Uncaught RangeError: Maximum call stack size exceeded"
//return window.btoa(String.fromCharCode.apply(null, new Uint8Array(buf)));
// The following version should work for longer inputs, util it doesn't
return window.btoa(
new Uint8Array(buf).reduce(function (data, byte) {
return data + String.fromCharCode(byte);
}, '')
);
}
}

Expand Down
60 changes: 60 additions & 0 deletions lib/course_storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { CourseData } from './gpx_parser';
import { base64ToString, digestSHA1, stringToBase64 } from './ab';

export type PersistedCourse = {
id: string;
name: string;
notes: string;
ts: number; // ms
course: CourseData;
};

export function getCourses(): PersistedCourse[] {
const courses: PersistedCourse[] = [];

if (typeof window === 'undefined') {
return courses;
}

for (let i in localStorage) {
if (i.startsWith('course:')) {
const v = JSON.parse(localStorage[i]);

courses.push({
id: i,
name: v.name,
notes: v.notes,
ts: v.ts,
course: JSON.parse(base64ToString(v.course)),
});
}
}

return courses.sort((a, b) => b.ts - a.ts);
}

export async function saveCourse(name: string, notes: string, course: CourseData, ts?: number): Promise<string> {
const courseStr = JSON.stringify(course);
const digest = await digestSHA1(`${name}${courseStr}`);
const id = `course:${digest}`;

localStorage.setItem(
id,
JSON.stringify({
name,
notes,
ts: ts ?? Date.now(),
course: await stringToBase64(courseStr),
})
);

return id;
}

export function deleteCourse(id: string) {
if (!id.startsWith('course:')) {
throw new Error('Not a course');
}

localStorage.removeItem(id);
}
10 changes: 9 additions & 1 deletion lib/workout_storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ export async function saveWorkout(name: string, notes: string, script: string, t
}

export async function toggleWorkoutFav(id: string) {
if (!id.startsWith('workout:')) {
throw new Error('Not a workout');
}

const raw = localStorage.getItem(id);

if (!raw) {
Expand All @@ -105,6 +109,10 @@ export async function toggleWorkoutFav(id: string) {
}

export function readWorkout(id: string): WorkoutScript {
if (!id.startsWith('workout:')) {
throw new Error('Not a workout');
}

const raw = localStorage.getItem(id);

if (!raw) {
Expand All @@ -126,7 +134,7 @@ export function readWorkout(id: string): WorkoutScript {

export function deleteWorkout(id: string) {
if (!id.startsWith('workout:')) {
throw new Error('The given id is not a workout script id');
throw new Error('Not a workout');
}

localStorage.removeItem(id);
Expand Down
31 changes: 24 additions & 7 deletions pages/ride/map/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import CourseList from '../../../components/CourseList';
import StartButton from '../../../components/StartButton';
import CreateCourse from '../../../components/CreateCourse';
import { CourseData, getMapBounds, gpxDocument2obj, parseGpxFile2Document } from '../../../lib/gpx_parser';
import { PersistedCourse, saveCourse } from '../../../lib/course_storage';

type OpenStreetMapArg = Parameters<typeof OpenStreetMap>[0];
type MapMarkerArg = Parameters<typeof MapMarker>[0];
Expand Down Expand Up @@ -75,12 +76,13 @@ export default function RideMap() {
width: '70vw',
height: '70vh',
};
const [changeCount, setChangeCount] = useState<number>(0);

useEffect(() => {
if (
map &&
bounds &&
[bounds.minlat, bounds.minlon, bounds.maxlat, bounds.maxlon].some((v) => Number.isFinite(v))
[bounds.minlat, bounds.minlon, bounds.maxlat, bounds.maxlon].every((v) => Number.isFinite(v))
) {
map.fitBounds([
[bounds.minlat, bounds.minlon],
Expand All @@ -106,22 +108,37 @@ export default function RideMap() {
};
const newCourse = (name: string, file: File) => {
(async () => {
let data: CourseData | null;
let data: CourseData;

if (file) {
data = await importGpx(file);
} else {
data = {
tracks: [],
routes: [],
waypoints: [],
};
setCourse(null);
}
if (name) {
setCourseName(name);
// NOP
} else if (data && data.routes.length && data.routes[0].name) {
setCourseName(data.routes[0].name);
name = data.routes[0].name;
} else if (data && data.tracks.length && data.tracks[0].name) {
setCourseName(data.tracks[0].name);
name = data.tracks[0].name;
} else {
clearCourseName();
name = DEFAULT_COURSE_NAME;
}
setCourseName(name);

await saveCourse(name, '', data);
setChangeCount(changeCount + 1);
})();
};
const selectCourse = (persistedCourse: PersistedCourse) => {
setCourse(persistedCourse.course);
setCourseName(persistedCourse.name);
};

return (
<Container maxWidth="md">
Expand Down Expand Up @@ -162,7 +179,7 @@ export default function RideMap() {
</ButtonGroup>
</Grid>
<Grid item xs={4}>
<CourseList height={mapSize.height} />
<CourseList height={mapSize.height} changeId={changeCount} onSelectCourse={selectCourse} />
</Grid>

<Grid item xs={8}>
Expand Down

1 comment on commit c09ece5

@vercel
Copy link

@vercel vercel bot commented on c09ece5 Oct 29, 2023

Choose a reason for hiding this comment

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

Successfully deployed to the following URLs:

bfree – ./

bfree.vercel.app
bfree-olliv.vercel.app
bfree-git-master-olliv.vercel.app

Please sign in to comment.