Lots of updates:

- Map
- Better submissions.
This commit is contained in:
Andrew Lalis 2023-01-26 16:49:51 +01:00
parent d1aa06dd95
commit e0433753ea
15 changed files with 2179 additions and 389 deletions

View File

@ -1,5 +1,6 @@
package nl.andrewlalis.gymboard_api.controller;
import jakarta.servlet.http.HttpServletResponse;
import nl.andrewlalis.gymboard_api.controller.dto.*;
import nl.andrewlalis.gymboard_api.service.ExerciseSubmissionService;
import nl.andrewlalis.gymboard_api.service.GymService;
@ -8,6 +9,8 @@ import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* Controller for accessing a particular gym.
*/
@ -29,6 +32,11 @@ public class GymController {
return gymService.getGym(CompoundGymId.parse(compoundId));
}
@GetMapping(path = "/recent-submissions")
public List<ExerciseSubmissionResponse> getRecentSubmissions(@PathVariable String compoundId) {
return gymService.getRecentSubmissions(CompoundGymId.parse(compoundId));
}
@PostMapping(path = "/submissions")
public ExerciseSubmissionResponse createSubmission(
@PathVariable String compoundId,
@ -38,10 +46,7 @@ public class GymController {
}
@GetMapping(path = "/submissions/{submissionId}")
public ExerciseSubmissionResponse getSubmission(
@PathVariable String compoundId,
@PathVariable long submissionId
) {
public ExerciseSubmissionResponse getSubmission(@PathVariable String compoundId, @PathVariable long submissionId) {
return submissionService.getSubmission(CompoundGymId.parse(compoundId), submissionId);
}
@ -52,4 +57,13 @@ public class GymController {
) {
return uploadService.handleSubmissionUpload(CompoundGymId.parse(compoundId), file);
}
@GetMapping(path = "/submissions/{submissionId}/video")
public void getSubmissionVideo(
@PathVariable String compoundId,
@PathVariable long submissionId,
HttpServletResponse response
) {
submissionService.streamVideo(CompoundGymId.parse(compoundId), submissionId, response);
}
}

View File

@ -2,11 +2,12 @@ package nl.andrewlalis.gymboard_api.dao.exercise;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ExerciseSubmissionRepository extends JpaRepository<ExerciseSubmission, Long> {
public interface ExerciseSubmissionRepository extends JpaRepository<ExerciseSubmission, Long>, JpaSpecificationExecutor<ExerciseSubmission> {
List<ExerciseSubmission> findAllByStatus(ExerciseSubmission.Status status);
}

View File

@ -1,5 +1,6 @@
package nl.andrewlalis.gymboard_api.service;
import jakarta.servlet.http.HttpServletResponse;
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse;
@ -32,6 +33,7 @@ import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
@ -78,6 +80,33 @@ public class ExerciseSubmissionService {
return new ExerciseSubmissionResponse(submission);
}
@Transactional(readOnly = true)
public void streamVideo(CompoundGymId compoundId, long submissionId, HttpServletResponse response) {
// TODO: Make a faster way to stream videos, should be one DB call instead of this mess.
Gym gym = gymRepository.findByCompoundId(compoundId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
ExerciseSubmission submission = exerciseSubmissionRepository.findById(submissionId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (!submission.getGym().getId().equals(gym.getId())) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
Set<ExerciseSubmission.Status> validStatuses = Set.of(
ExerciseSubmission.Status.COMPLETED,
ExerciseSubmission.Status.VERIFIED
);
if (!validStatuses.contains(submission.getStatus())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
ExerciseSubmissionVideoFile videoFile = submissionVideoFileRepository.findBySubmission(submission)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
response.setContentType(videoFile.getFile().getMimeType());
response.setContentLengthLong(videoFile.getFile().getSize());
try {
response.getOutputStream().write(videoFile.getFile().getContent());
} catch (IOException e) {
log.error("Failed to write submission video file to response.", e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
/**
* Handles the creation of a new exercise submission. This involves a few steps:
* <ol>

View File

@ -1,24 +1,34 @@
package nl.andrewlalis.gymboard_api.service;
import nl.andrewlalis.gymboard_api.controller.dto.GymResponse;
import jakarta.persistence.criteria.Predicate;
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse;
import nl.andrewlalis.gymboard_api.controller.dto.GymResponse;
import nl.andrewlalis.gymboard_api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseSubmissionRepository;
import nl.andrewlalis.gymboard_api.model.Gym;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList;
import java.util.List;
@Service
public class GymService {
private static final Logger log = LoggerFactory.getLogger(GymService.class);
private final GymRepository gymRepository;
private final ExerciseSubmissionRepository submissionRepository;
public GymService(GymRepository gymRepository) {
public GymService(GymRepository gymRepository, ExerciseSubmissionRepository submissionRepository) {
this.gymRepository = gymRepository;
this.submissionRepository = submissionRepository;
}
@Transactional(readOnly = true)
@ -28,5 +38,24 @@ public class GymService {
return new GymResponse(gym);
}
@Transactional(readOnly = true)
public List<ExerciseSubmissionResponse> getRecentSubmissions(CompoundGymId id) {
Gym gym = gymRepository.findByCompoundId(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return submissionRepository.findAll((root, query, criteriaBuilder) -> {
query.orderBy(criteriaBuilder.desc(root.get("createdAt")));
query.distinct(true);
List<Predicate> predicates = new ArrayList<>();
predicates.add(criteriaBuilder.equal(root.get("gym"), gym));
predicates.add(criteriaBuilder.or(
criteriaBuilder.equal(root.get("status"), ExerciseSubmission.Status.COMPLETED),
criteriaBuilder.equal(root.get("status"), ExerciseSubmission.Status.VERIFIED)
));
return criteriaBuilder.and(predicates.toArray(new Predicate[0]));
}, PageRequest.of(0, 10))
.map(ExerciseSubmissionResponse::new)
.toList();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -11,25 +11,28 @@
"test": "echo \"No test specified\" && exit 0"
},
"dependencies": {
"axios": "^0.21.1",
"vue-i18n": "^9.2.2",
"pinia": "^2.0.11",
"@quasar/cli": "^2.0.0",
"@quasar/extras": "^1.0.0",
"axios": "^0.21.1",
"pinia": "^2.0.11",
"quasar": "^2.6.0",
"vue": "^3.0.0",
"vue-i18n": "^9.2.2",
"vue-router": "^4.0.0"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.10.0",
"@typescript-eslint/parser": "^5.10.0",
"eslint": "^8.10.0",
"eslint-plugin-vue": "^9.0.0",
"eslint-config-prettier": "^8.1.0",
"prettier": "^2.5.1",
"@types/node": "^12.20.21",
"@intlify/vite-plugin-vue-i18n": "^3.3.1",
"@quasar/app-vite": "^1.0.0",
"@types/leaflet": "^1.9.0",
"@types/node": "^12.20.21",
"@typescript-eslint/eslint-plugin": "^5.10.0",
"@typescript-eslint/parser": "^5.10.0",
"autoprefixer": "^10.4.2",
"eslint": "^8.10.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-vue": "^9.0.0",
"leaflet": "^1.9.3",
"prettier": "^2.5.1",
"typescript": "^4.5.4"
},
"engines": {

View File

@ -0,0 +1 @@
{}

View File

@ -1,6 +1,7 @@
import { GeoPoint } from 'src/api/main/models';
import SubmissionsModule from 'src/api/main/submission';
import SubmissionsModule, {ExerciseSubmission} from 'src/api/main/submission';
import { api } from 'src/api/main/index';
import {GymRoutable} from "src/router/gym-routing";
export interface Gym {
countryCode: string;
@ -47,6 +48,13 @@ class GymsModule {
streetAddress: d.streetAddress,
};
}
public async getRecentSubmissions(gym: GymRoutable): Promise<Array<ExerciseSubmission>> {
const response = await api.get(
`/gyms/${gym.countryCode}_${gym.cityShortName}_${gym.shortName}/recent-submissions`
);
return response.data;
}
}
export default GymsModule;

View File

@ -1,7 +1,6 @@
import axios, { AxiosInstance } from 'axios';
import axios from 'axios';
import GymsModule from 'src/api/main/gyms';
import ExercisesModule from 'src/api/main/exercises';
import { GymRoutable } from 'src/router/gym-routing';
export const BASE_URL = 'http://localhost:8080';
@ -10,39 +9,8 @@ export const api = axios.create({
baseURL: BASE_URL,
});
/**
* The base class for all API modules.
*/
export abstract class ApiModule {
protected api: AxiosInstance;
protected constructor(api: AxiosInstance) {
this.api = api;
}
}
class GymboardApi {
public readonly gyms = new GymsModule();
public readonly exercises = new ExercisesModule();
/**
* Gets the URL for uploading a video file when creating an exercise submission
* for a gym.
* @param gym The gym that the submission is for.
*/
public getUploadUrl(gym: GymRoutable) {
return (
BASE_URL +
`/gyms/${gym.countryCode}_${gym.cityShortName}_${gym.shortName}/submissions/upload`
);
}
/**
* Gets the URL at which the raw file data for the given file id can be streamed.
* @param fileId The file id.
*/
public getFileUrl(fileId: number) {
return BASE_URL + `/files/${fileId}`;
}
}
export default new GymboardApi();

View File

@ -1,6 +1,6 @@
import { SimpleGym } from 'src/api/main/gyms';
import { Exercise } from 'src/api/main/exercises';
import { api } from 'src/api/main/index';
import {api, BASE_URL} from 'src/api/main/index';
import { GymRoutable } from 'src/router/gym-routing';
import { sleep } from 'src/utils';
@ -23,7 +23,9 @@ export interface ExerciseSubmission {
exercise: Exercise;
status: ExerciseSubmissionStatus;
submitterName: string;
weight: number;
rawWeight: number;
weightUnit: string;
metricWeight: number;
reps: number;
}
@ -46,6 +48,16 @@ class SubmissionsModule {
return response.data;
}
public getSubmissionVideoUrl(submission: ExerciseSubmission): string | null {
if (
submission.status !== ExerciseSubmissionStatus.COMPLETED &&
submission.status !== ExerciseSubmissionStatus.VERIFIED
) {
return null;
}
return BASE_URL + `/gyms/${submission.gym.countryCode}_${submission.gym.cityShortName}_${submission.gym.shortName}/submissions/${submission.id}/video`
}
public async createSubmission(
gym: GymRoutable,
payload: ExerciseSubmissionPayload

View File

@ -0,0 +1,32 @@
<template>
<q-expansion-item
expand-separator
:label="submission.rawWeight + ' ' + submission.weightUnit + ' x' + submission.reps + ' ' + submission.exercise.displayName"
:caption="submission.submitterName"
>
<q-card>
<q-card-section class="text-center">
<video
:src="api.gyms.submissions.getSubmissionVideoUrl(submission)"
width="600"
loop
controls
autopictureinpicture
preload="metadata"
/>
</q-card-section>
</q-card>
</q-expansion-item>
</template>
<script setup lang="ts">
import {ExerciseSubmission} from 'src/api/main/submission';
import api from 'src/api/main';
interface Props {
submission: ExerciseSubmission
}
defineProps<Props>();
</script>
<style scoped></style>

View File

@ -10,7 +10,12 @@ export default {
home: 'Home',
submit: 'Submit',
leaderboard: 'Leaderboard',
homePage: {
overview: 'Overview of this gym:',
recentLifts: 'Recent Lifts'
},
submitPage: {
name: 'Your Name',
exercise: 'Exercise',
weight: 'Weight',
reps: 'Repetitions',

View File

@ -10,7 +10,12 @@ export default {
home: 'Thuis',
submit: 'Indienen',
leaderboard: 'Scorebord',
homePage: {
overview: 'Overzicht van dit sportschool:',
recentLifts: 'Recente liften'
},
submitPage: {
name: 'Jouw naam',
exercise: 'Oefening',
weight: 'Gewicht',
reps: 'Repetities',

View File

@ -0,0 +1,3 @@
declare module 'leaflet';
export {};

View File

@ -1,12 +1,74 @@
<template>
<q-page>
<h3>Gym Home Page</h3>
<p>Maybe put an image of the gym here?</p>
<p>Put a description of the gym here?</p>
<p>Maybe show a snapshot of some recent lifts?</p>
<q-page v-if="gym">
<div class="row">
<div class="col-xs-12 col-md-6 q-pt-md">
<p>{{ $t('gymPage.homePage.overview') }}</p>
<ul>
<li>Website: <a :href="gym.websiteUrl">{{ gym.websiteUrl }}</a></li>
<li>Address: <em>{{ gym.streetAddress }}</em></li>
<li>City: <em>{{ gym.cityName }}</em></li>
<li>Country: <em>{{ gym.countryName }}</em></li>
<li>Registered at: <em>{{ gym.createdAt }}</em></li>
</ul>
</div>
<div class="col-xs-12 col-md-6">
<div ref="mapContainer" style="height: 300px; width: 100%"></div>
</div>
</div>
<div v-if="recentSubmissions.length > 0">
<h4 class="text-center">{{ $t('gymPage.homePage.recentLifts') }}</h4>
<q-list>
<ExerciseSubmissionListItem v-for="sub in recentSubmissions" :submission="sub" :key="sub.id"/>
</q-list>
</div>
</q-page>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import {nextTick, onMounted, ref, Ref} from 'vue';
import {ExerciseSubmission} from 'src/api/main/submission';
import api from 'src/api/main';
import {getGymFromRoute} from 'src/router/gym-routing';
import ExerciseSubmissionListItem from 'components/ExerciseSubmissionListItem.vue';
import {Gym} from 'src/api/main/gyms';
import 'leaflet/dist/leaflet.css';
import {Map, Marker, TileLayer} from 'leaflet';
const recentSubmissions: Ref<Array<ExerciseSubmission>> = ref([]);
const gym: Ref<Gym | undefined> = ref();
const TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
const ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>';
const map: Ref<Map | undefined> = ref();
const mapContainer = ref();
onMounted(async () => {
gym.value = await getGymFromRoute();
// We need to wait one tick for the main page to be loaded as a consequence of the gym being loaded.
await nextTick(() => initMap());
recentSubmissions.value = await api.gyms.getRecentSubmissions(gym.value);
});
function initMap() {
if (!gym.value) return;
const g: Gym = gym.value;
console.log(mapContainer);
const tiles = new TileLayer(TILE_URL, { attribution: ATTRIBUTION, maxZoom: 19 });
const marker = new Marker([g.location.latitude, g.location.longitude], {
title: g.displayName,
alt: g.displayName
});
map.value = new Map(mapContainer.value, {})
.setView([g.location.latitude, g.location.longitude], 16);
tiles.addTo(map.value);
marker.addTo(map.value);
setTimeout(() => {
map.value?.invalidateSize();
}, 400);
}
</script>
<style scoped></style>