parent
d1aa06dd95
commit
e0433753ea
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
@ -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": {
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
{}
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
declare module 'leaflet';
|
||||
|
||||
export {};
|
|
@ -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 = '© <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>
|
||||
|
|
Loading…
Reference in New Issue