Added better submission upload workflow.

This commit is contained in:
Andrew Lalis 2023-01-26 12:52:22 +01:00
parent ed3152aa2b
commit 3908c2becd
52 changed files with 636 additions and 590 deletions

View File

@ -8,12 +8,11 @@ import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
/** /**
* Controller for accessing a particular gym. * Controller for accessing a particular gym.
*/ */
@RestController @RestController
@RequestMapping(path = "/gyms/{compoundId}")
public class GymController { public class GymController {
private final GymService gymService; private final GymService gymService;
private final UploadService uploadService; private final UploadService uploadService;
@ -25,35 +24,32 @@ public class GymController {
this.submissionService = submissionService; this.submissionService = submissionService;
} }
@GetMapping(path = "/gyms/{countryCode}/{cityCode}/{gymName}") @GetMapping
public GymResponse getGym( public GymResponse getGym(@PathVariable String compoundId) {
@PathVariable String countryCode, return gymService.getGym(CompoundGymId.parse(compoundId));
@PathVariable String cityCode,
@PathVariable String gymName
) {
return gymService.getGym(new RawGymId(countryCode, cityCode, gymName));
} }
@PostMapping(path = "/gyms/{countryCode}/{cityCode}/{gymName}/submissions") @PostMapping(path = "/submissions")
public ExerciseSubmissionResponse createSubmission( public ExerciseSubmissionResponse createSubmission(
@PathVariable String countryCode, @PathVariable String compoundId,
@PathVariable String cityCode,
@PathVariable String gymName,
@RequestBody ExerciseSubmissionPayload payload @RequestBody ExerciseSubmissionPayload payload
) { ) {
return submissionService.createSubmission(new RawGymId(countryCode, cityCode, gymName), payload); return submissionService.createSubmission(CompoundGymId.parse(compoundId), payload);
} }
@PostMapping( @GetMapping(path = "/submissions/{submissionId}")
path = "/gyms/{countryCode}/{cityCode}/{gymName}/submissions/upload", public ExerciseSubmissionResponse getSubmission(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE @PathVariable String compoundId,
) @PathVariable long submissionId
) {
return submissionService.getSubmission(CompoundGymId.parse(compoundId), submissionId);
}
@PostMapping(path = "/submissions/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public UploadedFileResponse uploadVideo( public UploadedFileResponse uploadVideo(
@PathVariable String countryCode, @PathVariable String compoundId,
@PathVariable String cityCode,
@PathVariable String gymName,
@RequestParam MultipartFile file @RequestParam MultipartFile file
) { ) {
return uploadService.handleSubmissionUpload(new RawGymId(countryCode, cityCode, gymName), file); return uploadService.handleSubmissionUpload(CompoundGymId.parse(compoundId), file);
} }
} }

View File

@ -0,0 +1,28 @@
package nl.andrewlalis.gymboard_api.controller.dto;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public record CompoundGymId(String country, String city, String gym) {
/**
* Parses a compound gym id from a string expression.
* <p>
* For example, `nl_groningen_trainmore-munnekeholm`.
* </p>
* @param idStr The id string.
* @return The compound gym id.
* @throws ResponseStatusException A not found exception is thrown if the id
* string is invalid.
*/
public static CompoundGymId parse(String idStr) throws ResponseStatusException {
if (idStr == null || idStr.isBlank()) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
String[] parts = idStr.strip().toLowerCase().split("_");
if (parts.length != 3) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
return new CompoundGymId(
parts[0],
parts[1],
parts[2]
);
}
}

View File

@ -4,6 +4,7 @@ public record ExerciseSubmissionPayload(
String name, String name,
String exerciseShortName, String exerciseShortName,
float weight, float weight,
String weightUnit,
int reps, int reps,
long videoId long videoId
) {} ) {}

View File

@ -11,7 +11,9 @@ public record ExerciseSubmissionResponse(
ExerciseResponse exercise, ExerciseResponse exercise,
String status, String status,
String submitterName, String submitterName,
double weight, double rawWeight,
String weightUnit,
double metricWeight,
int reps int reps
) { ) {
public ExerciseSubmissionResponse(ExerciseSubmission submission) { public ExerciseSubmissionResponse(ExerciseSubmission submission) {
@ -22,7 +24,9 @@ public record ExerciseSubmissionResponse(
new ExerciseResponse(submission.getExercise()), new ExerciseResponse(submission.getExercise()),
submission.getStatus().name(), submission.getStatus().name(),
submission.getSubmitterName(), submission.getSubmitterName(),
submission.getWeight().doubleValue(), submission.getRawWeight().doubleValue(),
submission.getWeightUnit().name(),
submission.getMetricWeight().doubleValue(),
submission.getReps() submission.getReps()
); );
} }

View File

@ -1,3 +0,0 @@
package nl.andrewlalis.gymboard_api.controller.dto;
public record RawGymId(String countryCode, String cityCode, String gymName) {}

View File

@ -1,5 +1,6 @@
package nl.andrewlalis.gymboard_api.dao; package nl.andrewlalis.gymboard_api.dao;
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId;
import nl.andrewlalis.gymboard_api.model.Gym; import nl.andrewlalis.gymboard_api.model.Gym;
import nl.andrewlalis.gymboard_api.model.GymId; import nl.andrewlalis.gymboard_api.model.GymId;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
@ -12,8 +13,14 @@ import java.util.Optional;
@Repository @Repository
public interface GymRepository extends JpaRepository<Gym, GymId>, JpaSpecificationExecutor<Gym> { public interface GymRepository extends JpaRepository<Gym, GymId>, JpaSpecificationExecutor<Gym> {
@Query("SELECT g FROM Gym g " + @Query("SELECT g FROM Gym g " +
"WHERE g.id.shortName = :gym AND " + "WHERE g.id.shortName = :#{#id.gym()} AND " +
"g.id.city.id.shortName = :city AND " + "g.id.city.id.shortName = :#{#id.city()} AND " +
"g.id.city.id.country.code = :country") "g.id.city.id.country.code = :#{#id.country()}")
Optional<Gym> findByRawId(String gym, String city, String country); Optional<Gym> findByCompoundId(CompoundGymId id);
@Query("SELECT COUNT(g) > 0 FROM Gym g " +
"WHERE g.id.shortName = :#{#id.gym()} AND " +
"g.id.city.id.shortName = :#{#id.city()} AND " +
"g.id.city.id.country.code = :#{#id.country()}")
boolean existsByCompoundId(CompoundGymId id);
} }

View File

@ -13,4 +13,5 @@ import java.util.Optional;
public interface ExerciseSubmissionTempFileRepository extends JpaRepository<ExerciseSubmissionTempFile, Long> { public interface ExerciseSubmissionTempFileRepository extends JpaRepository<ExerciseSubmissionTempFile, Long> {
List<ExerciseSubmissionTempFile> findAllByCreatedAtBefore(LocalDateTime timestamp); List<ExerciseSubmissionTempFile> findAllByCreatedAtBefore(LocalDateTime timestamp);
Optional<ExerciseSubmissionTempFile> findBySubmission(ExerciseSubmission submission); Optional<ExerciseSubmissionTempFile> findBySubmission(ExerciseSubmission submission);
boolean existsByPath(String path);
} }

View File

@ -28,6 +28,11 @@ public class ExerciseSubmission {
VERIFIED VERIFIED
} }
public enum WeightUnit {
KG,
LBS
}
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
@ -49,18 +54,27 @@ public class ExerciseSubmission {
private String submitterName; private String submitterName;
@Column(nullable = false, precision = 7, scale = 2) @Column(nullable = false, precision = 7, scale = 2)
private BigDecimal weight; private BigDecimal rawWeight;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private WeightUnit weightUnit;
@Column(nullable = false, precision = 7, scale = 2)
private BigDecimal metricWeight;
@Column(nullable = false) @Column(nullable = false)
private int reps; private int reps;
public ExerciseSubmission() {} public ExerciseSubmission() {}
public ExerciseSubmission(Gym gym, Exercise exercise, String submitterName, BigDecimal weight, int reps) { public ExerciseSubmission(Gym gym, Exercise exercise, String submitterName, BigDecimal rawWeight, WeightUnit unit, BigDecimal metricWeight, int reps) {
this.gym = gym; this.gym = gym;
this.exercise = exercise; this.exercise = exercise;
this.submitterName = submitterName; this.submitterName = submitterName;
this.weight = weight; this.rawWeight = rawWeight;
this.weightUnit = unit;
this.metricWeight = metricWeight;
this.reps = reps; this.reps = reps;
this.status = Status.WAITING; this.status = Status.WAITING;
} }
@ -93,8 +107,16 @@ public class ExerciseSubmission {
return submitterName; return submitterName;
} }
public BigDecimal getWeight() { public BigDecimal getRawWeight() {
return weight; return rawWeight;
}
public WeightUnit getWeightUnit() {
return weightUnit;
}
public BigDecimal getMetricWeight() {
return metricWeight;
} }
public int getReps() { public int getReps() {

View File

@ -1,8 +1,8 @@
package nl.andrewlalis.gymboard_api.service; package nl.andrewlalis.gymboard_api.service;
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload; import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse; import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse;
import nl.andrewlalis.gymboard_api.controller.dto.RawGymId;
import nl.andrewlalis.gymboard_api.dao.GymRepository; import nl.andrewlalis.gymboard_api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.dao.StoredFileRepository; import nl.andrewlalis.gymboard_api.dao.StoredFileRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository; import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository;
@ -30,6 +30,7 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -63,6 +64,15 @@ public class ExerciseSubmissionService {
this.submissionVideoFileRepository = submissionVideoFileRepository; this.submissionVideoFileRepository = submissionVideoFileRepository;
} }
@Transactional(readOnly = true)
public ExerciseSubmissionResponse getSubmission(CompoundGymId id, long submissionId) {
Gym gym = gymRepository.findByCompoundId(id)
.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);
return new ExerciseSubmissionResponse(submission);
}
/** /**
* Handles the creation of a new exercise submission. This involves a few steps: * Handles the creation of a new exercise submission. This involves a few steps:
@ -79,8 +89,8 @@ public class ExerciseSubmissionService {
* @return The saved submission, which will be in the PROCESSING state at first. * @return The saved submission, which will be in the PROCESSING state at first.
*/ */
@Transactional @Transactional
public ExerciseSubmissionResponse createSubmission(RawGymId id, ExerciseSubmissionPayload payload) { public ExerciseSubmissionResponse createSubmission(CompoundGymId id, ExerciseSubmissionPayload payload) {
Gym gym = gymRepository.findByRawId(id.gymName(), id.cityCode(), id.countryCode()) Gym gym = gymRepository.findByCompoundId(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
Exercise exercise = exerciseRepository.findById(payload.exerciseShortName()) Exercise exercise = exerciseRepository.findById(payload.exerciseShortName())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid exercise.")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid exercise."));
@ -90,11 +100,20 @@ public class ExerciseSubmissionService {
// TODO: Validate the submission data. // TODO: Validate the submission data.
// Create the submission. // Create the submission.
BigDecimal rawWeight = BigDecimal.valueOf(payload.weight());
ExerciseSubmission.WeightUnit unit = ExerciseSubmission.WeightUnit.valueOf(payload.weightUnit().toUpperCase());
BigDecimal metricWeight = BigDecimal.valueOf(payload.weight());
if (unit == ExerciseSubmission.WeightUnit.LBS) {
metricWeight = metricWeight.multiply(new BigDecimal("0.45359237"));
}
ExerciseSubmission submission = exerciseSubmissionRepository.save(new ExerciseSubmission( ExerciseSubmission submission = exerciseSubmissionRepository.save(new ExerciseSubmission(
gym, gym,
exercise, exercise,
payload.name(), payload.name(),
BigDecimal.valueOf(payload.weight()), rawWeight,
unit,
metricWeight,
payload.reps() payload.reps()
)); ));
// Then link it to the temporary video file so the async task can find it. // Then link it to the temporary video file so the async task can find it.
@ -164,7 +183,7 @@ public class ExerciseSubmissionService {
} }
// Now we can try to process the video file into a compressed format that can be stored in the DB. // Now we can try to process the video file into a compressed format that can be stored in the DB.
Path dir = tempFilePath.getParent(); Path dir = UploadService.SUBMISSION_TEMP_FILE_DIR;
String tempFileName = tempFilePath.getFileName().toString(); String tempFileName = tempFilePath.getFileName().toString();
String tempFileBaseName = tempFileName.substring(0, tempFileName.length() - ".tmp".length()); String tempFileBaseName = tempFileName.substring(0, tempFileName.length() - ".tmp".length());
Path outFilePath = dir.resolve(tempFileBaseName + "-out.mp4"); Path outFilePath = dir.resolve(tempFileBaseName + "-out.mp4");
@ -255,4 +274,35 @@ public class ExerciseSubmissionService {
Files.deleteIfExists(tmpStdout); Files.deleteIfExists(tmpStdout);
Files.deleteIfExists(tmpStderr); Files.deleteIfExists(tmpStderr);
} }
@Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES)
public void removeOldUploadedFiles() {
// First remove any temp files older than 10 minutes.
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(10);
var tempFiles = tempFileRepository.findAllByCreatedAtBefore(cutoff);
for (var file : tempFiles) {
try {
Files.deleteIfExists(Path.of(file.getPath()));
tempFileRepository.delete(file);
log.info("Removed temporary submission file {} at {}.", file.getId(), file.getPath());
} catch (IOException e) {
log.error(String.format("Could not delete submission temp file %d at %s.", file.getId(), file.getPath()), e);
}
}
// Then remove any files in the directory which don't correspond to a valid file in the db.
try (var s = Files.list(UploadService.SUBMISSION_TEMP_FILE_DIR)) {
for (var path : s.toList()) {
if (!tempFileRepository.existsByPath(path.toString())) {
try {
Files.delete(path);
} catch (IOException e) {
log.error("Couldn't delete orphan temp file: " + path, e);
}
}
}
} catch (IOException e) {
log.error("Couldn't get list of temp files.", e);
}
}
} }

View File

@ -1,7 +1,7 @@
package nl.andrewlalis.gymboard_api.service; package nl.andrewlalis.gymboard_api.service;
import nl.andrewlalis.gymboard_api.controller.dto.GymResponse; import nl.andrewlalis.gymboard_api.controller.dto.GymResponse;
import nl.andrewlalis.gymboard_api.controller.dto.RawGymId; import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId;
import nl.andrewlalis.gymboard_api.dao.GymRepository; import nl.andrewlalis.gymboard_api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.model.Gym; import nl.andrewlalis.gymboard_api.model.Gym;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -22,8 +22,8 @@ public class GymService {
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public GymResponse getGym(RawGymId id) { public GymResponse getGym(CompoundGymId id) {
Gym gym = gymRepository.findByRawId(id.gymName(), id.cityCode(), id.countryCode()) Gym gym = gymRepository.findByCompoundId(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return new GymResponse(gym); return new GymResponse(gym);
} }

View File

@ -1,6 +1,6 @@
package nl.andrewlalis.gymboard_api.service; package nl.andrewlalis.gymboard_api.service;
import nl.andrewlalis.gymboard_api.controller.dto.RawGymId; import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId;
import nl.andrewlalis.gymboard_api.controller.dto.UploadedFileResponse; import nl.andrewlalis.gymboard_api.controller.dto.UploadedFileResponse;
import nl.andrewlalis.gymboard_api.dao.GymRepository; import nl.andrewlalis.gymboard_api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseSubmissionTempFileRepository; import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseSubmissionTempFileRepository;
@ -21,6 +21,7 @@ import java.nio.file.Path;
*/ */
@Service @Service
public class UploadService { public class UploadService {
public static final Path SUBMISSION_TEMP_FILE_DIR = Path.of("exercise_submission_temp_files");
private static final String[] ALLOWED_VIDEO_TYPES = { private static final String[] ALLOWED_VIDEO_TYPES = {
"video/mp4" "video/mp4"
}; };
@ -46,8 +47,8 @@ public class UploadService {
* the user's submission. * the user's submission.
*/ */
@Transactional @Transactional
public UploadedFileResponse handleSubmissionUpload(RawGymId gymId, MultipartFile multipartFile) { public UploadedFileResponse handleSubmissionUpload(CompoundGymId gymId, MultipartFile multipartFile) {
Gym gym = gymRepository.findByRawId(gymId.gymName(), gymId.cityCode(), gymId.countryCode()) Gym gym = gymRepository.findByCompoundId(gymId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
// TODO: Check that user is allowed to upload. // TODO: Check that user is allowed to upload.
boolean fileTypeAcceptable = false; boolean fileTypeAcceptable = false;
@ -61,11 +62,10 @@ public class UploadService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid content type."); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid content type.");
} }
try { try {
Path tempFileDir = Path.of("exercise_submission_temp_files"); if (!Files.exists(SUBMISSION_TEMP_FILE_DIR)) {
if (!Files.exists(tempFileDir)) { Files.createDirectory(SUBMISSION_TEMP_FILE_DIR);
Files.createDirectory(tempFileDir);
} }
Path tempFilePath = Files.createTempFile(tempFileDir, null, null); Path tempFilePath = Files.createTempFile(SUBMISSION_TEMP_FILE_DIR, null, null);
multipartFile.transferTo(tempFilePath); multipartFile.transferTo(tempFilePath);
ExerciseSubmissionTempFile tempFileEntity = tempFileRepository.save(new ExerciseSubmissionTempFile(tempFilePath.toString())); ExerciseSubmissionTempFile tempFileEntity = tempFileRepository.save(new ExerciseSubmissionTempFile(tempFilePath.toString()));
return new UploadedFileResponse(tempFileEntity.getId()); return new UploadedFileResponse(tempFileEntity.getId());

View File

@ -9,14 +9,14 @@ module.exports = {
// `parser: 'vue-eslint-parser'` is already included with any 'plugin:vue/**' config and should be omitted // `parser: 'vue-eslint-parser'` is already included with any 'plugin:vue/**' config and should be omitted
parserOptions: { parserOptions: {
parser: require.resolve('@typescript-eslint/parser'), parser: require.resolve('@typescript-eslint/parser'),
extraFileExtensions: [ '.vue' ] extraFileExtensions: ['.vue'],
}, },
env: { env: {
browser: true, browser: true,
es2021: true, es2021: true,
node: true, node: true,
'vue/setup-compiler-macros': true 'vue/setup-compiler-macros': true,
}, },
// Rules order is important, please avoid shuffling them // Rules order is important, please avoid shuffling them
@ -37,7 +37,7 @@ module.exports = {
// https://github.com/prettier/eslint-config-prettier#installation // https://github.com/prettier/eslint-config-prettier#installation
// usage with Prettier, provided by 'eslint-config-prettier'. // usage with Prettier, provided by 'eslint-config-prettier'.
'prettier' 'prettier',
], ],
plugins: [ plugins: [
@ -46,12 +46,11 @@ module.exports = {
// https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-files // https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-files
// required to lint *.vue files // required to lint *.vue files
'vue' 'vue',
// https://github.com/typescript-eslint/typescript-eslint/issues/389#issuecomment-509292674 // https://github.com/typescript-eslint/typescript-eslint/issues/389#issuecomment-509292674
// Prettier has not been included as plugin to avoid performance impact // Prettier has not been included as plugin to avoid performance impact
// add it as an extension for your IDE // add it as an extension for your IDE
], ],
globals: { globals: {
@ -64,12 +63,11 @@ module.exports = {
__QUASAR_SSR_PWA__: 'readonly', __QUASAR_SSR_PWA__: 'readonly',
process: 'readonly', process: 'readonly',
Capacitor: 'readonly', Capacitor: 'readonly',
chrome: 'readonly' chrome: 'readonly',
}, },
// add your custom rules here // add your custom rules here
rules: { rules: {
'prefer-promise-reject-errors': 'off', 'prefer-promise-reject-errors': 'off',
quotes: ['warn', 'single', { avoidEscape: true }], quotes: ['warn', 'single', { avoidEscape: true }],
@ -85,6 +83,6 @@ module.exports = {
'no-unused-vars': 'off', 'no-unused-vars': 'off',
// allow debugger during development only // allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
} },
} };

View File

@ -12,4 +12,4 @@
"dbaeumer.jshint", "dbaeumer.jshint",
"ms-vscode.vscode-typescript-tslint-plugin" "ms-vscode.vscode-typescript-tslint-plugin"
] ]
} }

View File

@ -3,14 +3,7 @@
"editor.guides.bracketPairs": true, "editor.guides.bracketPairs": true,
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": [ "editor.codeActionsOnSave": ["source.fixAll.eslint"],
"source.fixAll.eslint" "eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"],
],
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"vue"
],
"typescript.tsdk": "node_modules/typescript/lib" "typescript.tsdk": "node_modules/typescript/lib"
} }

View File

@ -3,6 +3,7 @@
Web app for Gymboard Web app for Gymboard
## Install the dependencies ## Install the dependencies
```bash ```bash
yarn yarn
# or # or
@ -10,32 +11,33 @@ npm install
``` ```
### Start the app in development mode (hot-code reloading, error reporting, etc.) ### Start the app in development mode (hot-code reloading, error reporting, etc.)
```bash ```bash
quasar dev quasar dev
``` ```
### Lint the files ### Lint the files
```bash ```bash
yarn lint yarn lint
# or # or
npm run lint npm run lint
``` ```
### Format the files ### Format the files
```bash ```bash
yarn format yarn format
# or # or
npm run format npm run format
``` ```
### Build the app for production ### Build the app for production
```bash ```bash
quasar build quasar build
``` ```
### Customize the configuration ### Customize the configuration
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js). See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).

View File

@ -3,17 +3,40 @@
<head> <head>
<title><%= productName %></title> <title><%= productName %></title>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="description" content="<%= productDescription %>"> <meta name="description" content="<%= productDescription %>" />
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no"> <meta name="msapplication-tap-highlight" content="no" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>"> <meta
name="viewport"
content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>"
/>
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png"> <link
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png"> rel="icon"
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png"> type="image/png"
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png"> sizes="128x128"
<link rel="icon" type="image/ico" href="favicon.ico"> href="icons/favicon-128x128.png"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="icons/favicon-96x96.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="icons/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="icons/favicon-16x16.png"
/>
<link rel="icon" type="image/ico" href="favicon.ico" />
</head> </head>
<body> <body>
<!-- quasar:entry-point --> <!-- quasar:entry-point -->

View File

@ -37,4 +37,4 @@
"npm": ">= 6.13.4", "npm": ">= 6.13.4",
"yarn": ">= 1.21.1" "yarn": ">= 1.21.1"
} }
} }

View File

@ -13,9 +13,9 @@ module.exports = {
'last 4 Android versions', 'last 4 Android versions',
'last 4 ChromeAndroid versions', 'last 4 ChromeAndroid versions',
'last 4 FirefoxAndroid versions', 'last 4 FirefoxAndroid versions',
'last 4 iOS versions' 'last 4 iOS versions',
] ],
}) }),
// https://github.com/elchininet/postcss-rtlcss // https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL css, then // If you want to support RTL css, then
@ -23,5 +23,5 @@ module.exports = {
// 2. optionally set quasar.config.js > framework > lang to an RTL language // 2. optionally set quasar.config.js > framework > lang to an RTL language
// 3. uncomment the following line: // 3. uncomment the following line:
// require('postcss-rtlcss') // require('postcss-rtlcss')
] ],
} };

View File

@ -8,7 +8,6 @@
// Configuration for your app // Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
const { configure } = require('quasar/wrappers'); const { configure } = require('quasar/wrappers');
const path = require('path'); const path = require('path');
const { withCtx } = require('vue'); const { withCtx } = require('vue');
@ -21,7 +20,7 @@ module.exports = configure(function (ctx) {
// exclude = [], // exclude = [],
// rawOptions = {}, // rawOptions = {},
warnings: true, warnings: true,
errors: true errors: true,
}, },
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature // https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
@ -30,15 +29,10 @@ module.exports = configure(function (ctx) {
// app boot file (/src/boot) // app boot file (/src/boot)
// --> boot files are part of "main.js" // --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files // https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: [ boot: ['i18n', 'axios'],
'i18n',
'axios',
],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
css: [ css: ['app.scss'],
'app.scss'
],
// https://github.com/quasarframework/quasar/tree/dev/extras // https://github.com/quasarframework/quasar/tree/dev/extras
extras: [ extras: [
@ -57,8 +51,8 @@ module.exports = configure(function (ctx) {
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
build: { build: {
target: { target: {
browser: [ 'es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1' ], browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
node: 'node16' node: 'node16',
}, },
vueRouterMode: 'hash', // available values: 'hash', 'history' vueRouterMode: 'hash', // available values: 'hash', 'history'
@ -71,7 +65,7 @@ module.exports = configure(function (ctx) {
// publicPath: '/', // publicPath: '/',
// analyze: true, // analyze: true,
env: { env: {
API: ctx.dev ? 'http://localhost:8080' : 'https://api.gymboard.com' API: ctx.dev ? 'http://localhost:8080' : 'https://api.gymboard.com',
}, },
// rawDefine: {} // rawDefine: {}
// ignorePublicFolder: true, // ignorePublicFolder: true,
@ -83,20 +77,23 @@ module.exports = configure(function (ctx) {
// viteVuePluginOptions: {}, // viteVuePluginOptions: {},
vitePlugins: [ vitePlugins: [
['@intlify/vite-plugin-vue-i18n', { [
// if you want to use Vue I18n Legacy API, you need to set `compositionOnly: false` '@intlify/vite-plugin-vue-i18n',
// compositionOnly: false, {
// if you want to use Vue I18n Legacy API, you need to set `compositionOnly: false`
// compositionOnly: false,
// you need to set i18n resource including paths ! // you need to set i18n resource including paths !
include: path.resolve(__dirname, './src/i18n/**') include: path.resolve(__dirname, './src/i18n/**'),
}] },
] ],
],
}, },
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer
devServer: { devServer: {
// https: true // https: true
open: true // opens browser window automatically open: true, // opens browser window automatically
}, },
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
@ -114,7 +111,7 @@ module.exports = configure(function (ctx) {
// directives: [], // directives: [],
// Quasar plugins // Quasar plugins
plugins: [] plugins: [],
}, },
// animations: 'all', // --- includes all animations // animations: 'all', // --- includes all animations
@ -136,7 +133,7 @@ module.exports = configure(function (ctx) {
// https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr // https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
ssr: { ssr: {
// ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name! // ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
// will mess up SSR // will mess up SSR
// extendSSRWebserverConf (esbuildConf) {}, // extendSSRWebserverConf (esbuildConf) {},
// extendPackageJson (json) {}, // extendPackageJson (json) {},
@ -147,11 +144,11 @@ module.exports = configure(function (ctx) {
// manualPostHydrationTrigger: true, // manualPostHydrationTrigger: true,
prodPort: 3000, // The default port that the production server should use prodPort: 3000, // The default port that the production server should use
// (gets superseded if process.env.PORT is specified at runtime) // (gets superseded if process.env.PORT is specified at runtime)
middlewares: [ middlewares: [
'render' // keep this as last one 'render', // keep this as last one
] ],
}, },
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa // https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
@ -175,7 +172,7 @@ module.exports = configure(function (ctx) {
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor
capacitor: { capacitor: {
hideSplashscreen: true hideSplashscreen: true,
}, },
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron
@ -189,13 +186,11 @@ module.exports = configure(function (ctx) {
packager: { packager: {
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options // https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
// OS X / Mac App Store // OS X / Mac App Store
// appBundleId: '', // appBundleId: '',
// appCategoryType: '', // appCategoryType: '',
// osxSign: '', // osxSign: '',
// protocol: 'myapp://path', // protocol: 'myapp://path',
// Windows only // Windows only
// win32metadata: { ... } // win32metadata: { ... }
}, },
@ -203,18 +198,16 @@ module.exports = configure(function (ctx) {
builder: { builder: {
// https://www.electron.build/configuration/configuration // https://www.electron.build/configuration/configuration
appId: 'gymboard-app' appId: 'gymboard-app',
} },
}, },
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex
bex: { bex: {
contentScripts: [ contentScripts: ['my-content-script'],
'my-content-script'
],
// extendBexScriptsConf (esbuildConf) {} // extendBexScriptsConf (esbuildConf) {}
// extendBexManifestJson (json) {} // extendBexManifestJson (json) {}
} },
} };
}); });

View File

@ -6,6 +6,6 @@
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
export default defineComponent({ export default defineComponent({
name: 'App' name: 'App',
}); });
</script> </script>

View File

@ -1,119 +0,0 @@
import axios from 'axios';
export const BASE_URL = 'http://localhost:8080';
// TODO: Figure out how to get the base URL from environment.
const api = axios.create({
baseURL: BASE_URL
});
api.defaults.headers.post['Content-Type'] = 'application/json';
export interface Exercise {
shortName: string,
displayName: string
}
export interface GeoPoint {
latitude: number,
longitude: number
}
export interface ExerciseSubmissionPayload {
name: string,
exerciseShortName: string,
weight: number,
reps: number,
videoId: number
}
export interface ExerciseSubmission {
id: number,
createdAt: string,
gym: SimpleGym,
exercise: Exercise,
status: ExerciseSubmissionStatus,
submitterName: string,
weight: number,
reps: number
}
export enum ExerciseSubmissionStatus {
WAITING = 'WAITING',
PROCESSING = 'PROCESSING',
FAILED = 'FAILED',
COMPLETED = 'COMPLETED',
VERIFIED = 'VERIFIED'
}
export interface Gym {
countryCode: string,
countryName: string,
cityShortName: string,
cityName: string,
createdAt: Date,
shortName: string,
displayName: string,
websiteUrl: string | null,
location: GeoPoint,
streetAddress: string
}
export interface SimpleGym {
countryCode: string,
cityShortName: string,
shortName: string,
displayName: string
}
/**
* 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.
*/
export function getUploadUrl(gym: Gym) {
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.
*/
export function getFileUrl(fileId: number) {
return BASE_URL + `/files/${fileId}`;
}
export async function getExercises(): Promise<Array<Exercise>> {
const response = await api.get('/exercises');
return response.data;
}
export async function getGym(
countryCode: string,
cityShortName: string,
gymShortName: string
): Promise<Gym> {
const response = await api.get(
`/gyms/${countryCode}/${cityShortName}/${gymShortName}`
);
const d = response.data;
return {
countryCode: d.countryCode,
countryName: d.countryName,
cityShortName: d.cityShortName,
cityName: d.cityName,
createdAt: new Date(d.createdAt),
shortName: d.shortName,
displayName: d.displayName,
websiteUrl: d.websiteUrl,
location: d.location,
streetAddress: d.streetAddress,
};
}
export async function createSubmission(gym: Gym, payload: ExerciseSubmissionPayload): Promise<ExerciseSubmission> {
const response = await api.post(
`/gyms/${gym.countryCode}/${gym.cityShortName}/${gym.shortName}/submissions`,
payload
);
return response.data;
}

View File

@ -0,0 +1,15 @@
import { api } from 'src/api/main/index';
export interface Exercise {
shortName: string;
displayName: string;
}
class ExercisesModule {
public async getExercises(): Promise<Array<Exercise>> {
const response = await api.get('/exercises');
return response.data;
}
}
export default ExercisesModule;

View File

@ -0,0 +1,52 @@
import { GeoPoint } from 'src/api/main/models';
import SubmissionsModule from 'src/api/main/submission';
import { api } from 'src/api/main/index';
export interface Gym {
countryCode: string;
countryName: string;
cityShortName: string;
cityName: string;
createdAt: Date;
shortName: string;
displayName: string;
websiteUrl: string | null;
location: GeoPoint;
streetAddress: string;
}
export interface SimpleGym {
countryCode: string;
cityShortName: string;
shortName: string;
displayName: string;
}
class GymsModule {
public readonly submissions: SubmissionsModule = new SubmissionsModule();
public async getGym(
countryCode: string,
cityShortName: string,
gymShortName: string
) {
const response = await api.get(
`/gyms/${countryCode}_${cityShortName}_${gymShortName}`
);
const d = response.data;
return {
countryCode: d.countryCode,
countryName: d.countryName,
cityShortName: d.cityShortName,
cityName: d.cityName,
createdAt: new Date(d.createdAt),
shortName: d.shortName,
displayName: d.displayName,
websiteUrl: d.websiteUrl,
location: d.location,
streetAddress: d.streetAddress,
};
}
}
export default GymsModule;

View File

@ -0,0 +1,48 @@
import axios, { AxiosInstance } 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';
// TODO: Figure out how to get the base URL from environment.
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

@ -0,0 +1,4 @@
export interface GeoPoint {
latitude: number;
longitude: number;
}

View File

@ -0,0 +1,105 @@
import { SimpleGym } from 'src/api/main/gyms';
import { Exercise } from 'src/api/main/exercises';
import { api } from 'src/api/main/index';
import { GymRoutable } from 'src/router/gym-routing';
import { sleep } from 'src/utils';
/**
* The data that's sent when creating a submission.
*/
export interface ExerciseSubmissionPayload {
name: string;
exerciseShortName: string;
weight: number;
weightUnit: string;
reps: number;
videoId: number;
}
export interface ExerciseSubmission {
id: number;
createdAt: string;
gym: SimpleGym;
exercise: Exercise;
status: ExerciseSubmissionStatus;
submitterName: string;
weight: number;
reps: number;
}
export enum ExerciseSubmissionStatus {
WAITING = 'WAITING',
PROCESSING = 'PROCESSING',
FAILED = 'FAILED',
COMPLETED = 'COMPLETED',
VERIFIED = 'VERIFIED',
}
class SubmissionsModule {
public async getSubmission(
gym: GymRoutable,
submissionId: number
): Promise<ExerciseSubmission> {
const response = await api.get(
`/gyms/${gym.countryCode}_${gym.cityShortName}_${gym.shortName}/submissions/${submissionId}`
);
return response.data;
}
public async createSubmission(
gym: GymRoutable,
payload: ExerciseSubmissionPayload
): Promise<ExerciseSubmission> {
const response = await api.post(
`/gyms/${gym.countryCode}_${gym.cityShortName}_${gym.shortName}/submissions`,
payload
);
return response.data;
}
public async uploadVideoFile(gym: GymRoutable, file: File): Promise<number> {
const formData = new FormData();
formData.append('file', file);
const response = await api.post(
`/gyms/${gym.countryCode}_${gym.cityShortName}_${gym.shortName}/submissions/upload`,
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
}
);
return response.data.id as number;
}
/**
* Asynchronous method that waits until a submission is done processing.
* @param gym The gym that the submission is for.
* @param submissionId The submission's id.
*/
public async waitUntilSubmissionProcessed(
gym: GymRoutable,
submissionId: number
): Promise<ExerciseSubmission> {
let failureCount = 0;
let attemptCount = 0;
while (failureCount < 5 && attemptCount < 60) {
await sleep(1000);
attemptCount++;
try {
const response = await this.getSubmission(gym, submissionId);
failureCount = 0;
if (
response.status !== ExerciseSubmissionStatus.WAITING &&
response.status !== ExerciseSubmissionStatus.PROCESSING
) {
return response;
}
} catch (error) {
console.log(error);
failureCount++;
}
}
throw new Error('Failed to wait for submission to complete.');
}
}
export default SubmissionsModule;

View File

@ -1,15 +1,17 @@
import axios from 'axios'; import axios from 'axios';
import {GymSearchResult} from 'src/api/search/models'; import { GymSearchResult } from 'src/api/search/models';
const api = axios.create({ const api = axios.create({
baseURL: 'http://localhost:8081' baseURL: 'http://localhost:8081',
}); });
/** /**
* Searches for gyms using the given query, and eventually returns results. * Searches for gyms using the given query, and eventually returns results.
* @param query The query to use. * @param query The query to use.
*/ */
export async function searchGyms(query: string): Promise<Array<GymSearchResult>> { export async function searchGyms(
query: string
): Promise<Array<GymSearchResult>> {
const response = await api.get('/search/gyms?q=' + query); const response = await api.get('/search/gyms?q=' + query);
return response.data; return response.data;
} }

View File

@ -1,12 +1,12 @@
export interface GymSearchResult { export interface GymSearchResult {
compoundId: string, compoundId: string;
shortName: string, shortName: string;
displayName: string, displayName: string;
cityShortName: string, cityShortName: string;
cityName: string, cityName: string;
countryCode: string, countryCode: string;
countryName: string, countryName: string;
streetAddress: string, streetAddress: string;
latitude: number, latitude: number;
longitude: number longitude: number;
} }

View File

@ -5,7 +5,7 @@ import messages from 'src/i18n';
export type MessageLanguages = keyof typeof messages; export type MessageLanguages = keyof typeof messages;
// Type-define 'en-US' as the master schema for the resource // Type-define 'en-US' as the master schema for the resource
export type MessageSchema = typeof messages['en-US']; export type MessageSchema = (typeof messages)['en-US'];
// See https://vue-i18n.intlify.dev/guide/advanced/typescript.html#global-resource-schema-type-definition // See https://vue-i18n.intlify.dev/guide/advanced/typescript.html#global-resource-schema-type-definition
/* eslint-disable @typescript-eslint/no-empty-interface */ /* eslint-disable @typescript-eslint/no-empty-interface */

View File

@ -1,14 +1,6 @@
<template> <template>
<q-item <q-item clickable tag="a" target="_blank" :href="link">
clickable <q-item-section v-if="icon" avatar>
tag="a"
target="_blank"
:href="link"
>
<q-item-section
v-if="icon"
avatar
>
<q-icon :name="icon" /> <q-icon :name="icon" />
</q-item-section> </q-item-section>
@ -27,23 +19,23 @@ export default defineComponent({
props: { props: {
title: { title: {
type: String, type: String,
required: true required: true,
}, },
caption: { caption: {
type: String, type: String,
default: '' default: '',
}, },
link: { link: {
type: String, type: String,
default: '#' default: '#',
}, },
icon: { icon: {
type: String, type: String,
default: '' default: '',
} },
} },
}); });
</script> </script>

View File

@ -1,64 +0,0 @@
<template>
<div>
<p>{{ title }}</p>
<ul>
<li v-for="todo in todos" :key="todo.id" @click="increment">
{{ todo.id }} - {{ todo.content }}
</li>
</ul>
<p>Count: {{ todoCount }} / {{ meta.totalCount }}</p>
<p>Active: {{ active ? 'yes' : 'no' }}</p>
<p>Clicks on todos: {{ clickCount }}</p>
</div>
</template>
<script lang="ts">
import {
defineComponent,
PropType,
computed,
ref,
toRef,
Ref,
} from 'vue';
import { Todo, Meta } from './models';
function useClickCount() {
const clickCount = ref(0);
function increment() {
clickCount.value += 1
return clickCount.value;
}
return { clickCount, increment };
}
function useDisplayTodo(todos: Ref<Todo[]>) {
const todoCount = computed(() => todos.value.length);
return { todoCount };
}
export default defineComponent({
name: 'ExampleComponent',
props: {
title: {
type: String,
required: true
},
todos: {
type: Array as PropType<Todo[]>,
default: () => []
},
meta: {
type: Object as PropType<Meta>,
required: true
},
active: {
type: Boolean
}
},
setup (props) {
return { ...useClickCount(), ...useDisplayTodo(toRef(props, 'todos')) };
},
});
</script>

View File

@ -6,21 +6,19 @@
<q-item-label caption lines="1">{{ gym.countryName }}</q-item-label> <q-item-label caption lines="1">{{ gym.countryName }}</q-item-label>
</q-item-section> </q-item-section>
<q-item-section side top> <q-item-section side top>
<q-badge color="primary" label="10k"/> <q-badge color="primary" label="10k" />
</q-item-section> </q-item-section>
</q-item> </q-item>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {getGymRoute} from 'src/router/gym-routing'; import { getGymRoute } from 'src/router/gym-routing';
import {GymSearchResult} from 'src/api/search/models'; import { GymSearchResult } from 'src/api/search/models';
interface Props { interface Props {
gym: GymSearchResult gym: GymSearchResult;
} }
defineProps<Props>(); defineProps<Props>();
</script> </script>
<style scoped> <style scoped></style>
</style>

View File

@ -1,8 +0,0 @@
export interface Todo {
id: number;
content: string;
}
export interface Meta {
totalCount: number;
}

View File

@ -12,14 +12,14 @@
// to match your app's branding. // to match your app's branding.
// Tip: Use the "Theme Builder" on Quasar's documentation website. // Tip: Use the "Theme Builder" on Quasar's documentation website.
$primary : #171717; $primary: #171717;
$secondary : #575757; $secondary: #575757;
$accent : #b60600; $accent: #b60600;
$dark : #1D1D1D; $dark: #1d1d1d;
$dark-page : #121212; $dark-page: #121212;
$positive : #41b65c; $positive: #41b65c;
$negative : #c0293b; $negative: #c0293b;
$info : #8bcfdd; $info: #8bcfdd;
$warning : #eccb70; $warning: #eccb70;

View File

@ -1,10 +1,10 @@
export default { export default {
mainLayout: { mainLayout: {
language: 'Language', language: 'Language',
pages: 'Pages' pages: 'Pages',
}, },
indexPage: { indexPage: {
searchHint: 'Search for a Gym' searchHint: 'Search for a Gym',
}, },
gymPage: { gymPage: {
home: 'Home', home: 'Home',
@ -15,7 +15,8 @@ export default {
weight: 'Weight', weight: 'Weight',
reps: 'Repetitions', reps: 'Repetitions',
date: 'Date', date: 'Date',
submit: 'Submit' upload: 'Video File to Upload',
} submit: 'Submit',
} },
},
}; };

View File

@ -3,5 +3,5 @@ import nlNL from './nl-NL';
export default { export default {
'en-US': enUS, 'en-US': enUS,
'nl-NL': nlNL 'nl-NL': nlNL,
}; };

View File

@ -1,10 +1,10 @@
export default { export default {
mainLayout: { mainLayout: {
language: 'Taal', language: 'Taal',
pages: 'Pagina\'s' pages: "Pagina's",
}, },
indexPage: { indexPage: {
searchHint: 'Zoek een sportschool' searchHint: 'Zoek een sportschool',
}, },
gymPage: { gymPage: {
home: 'Thuis', home: 'Thuis',
@ -15,7 +15,8 @@ export default {
weight: 'Gewicht', weight: 'Gewicht',
reps: 'Repetities', reps: 'Repetities',
date: 'Datum', date: 'Datum',
submit: 'Sturen' upload: 'Videobestand om te uploaden',
} submit: 'Sturen',
} },
} },
};

View File

@ -12,7 +12,9 @@
/> />
<q-toolbar-title> <q-toolbar-title>
<router-link to="/" style="text-decoration: none; color: inherit;">Gymboard</router-link> <router-link to="/" style="text-decoration: none; color: inherit"
>Gymboard</router-link
>
</q-toolbar-title> </q-toolbar-title>
<q-select <q-select
v-model="i18n.locale.value" v-model="i18n.locale.value"
@ -34,15 +36,9 @@
</q-toolbar> </q-toolbar>
</q-header> </q-header>
<q-drawer <q-drawer v-model="leftDrawerOpen" show-if-above bordered>
v-model="leftDrawerOpen"
show-if-above
bordered
>
<q-list> <q-list>
<q-item-label <q-item-label header>
header
>
{{ $t('mainLayout.pages') }} {{ $t('mainLayout.pages') }}
</q-item-label> </q-item-label>
<q-item clickable>Gyms</q-item> <q-item clickable>Gyms</q-item>
@ -58,17 +54,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import {useI18n} from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const i18n = useI18n({useScope: 'global'}); const i18n = useI18n({ useScope: 'global' });
const localeOptions = [ const localeOptions = [
{ value: 'en-US', label: 'English' }, { value: 'en-US', label: 'English' },
{ value: 'nl-NL', label: 'Nederlands' } { value: 'nl-NL', label: 'Nederlands' },
]; ];
const leftDrawerOpen = ref(false); const leftDrawerOpen = ref(false);
function toggleLeftDrawer () { function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value leftDrawerOpen.value = !leftDrawerOpen.value;
} }
</script> </script>

View File

@ -1,13 +1,11 @@
<template> <template>
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center"> <div
class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center"
>
<div> <div>
<div style="font-size: 30vh"> <div style="font-size: 30vh">404</div>
404
</div>
<div class="text-h2" style="opacity:.4"> <div class="text-h2" style="opacity: 0.4">Oops. Nothing here...</div>
Oops. Nothing here...
</div>
<q-btn <q-btn
class="q-mt-xl" class="q-mt-xl"
@ -26,6 +24,6 @@
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
export default defineComponent({ export default defineComponent({
name: 'ErrorNotFound' name: 'ErrorNotFound',
}); });
</script> </script>

View File

@ -1,95 +0,0 @@
<template>
<q-page v-if="gym" padding>
<h3 class="q-mt-none">{{ gym.displayName }}</h3>
<p>Recent top lifts go here.</p>
<div class="q-pa-md" style="max-width: 400px">
<q-form @submit="onSubmit" @reset="onReset" class="q-gutter-md">
<h5>Submit your lift!</h5>
<q-input
v-model="submissionModel.name"
filled
label="Name"
/>
<q-input
v-model="submissionModel.exercise"
filled
label="Exercise"
/>
<q-input
v-model="submissionModel.weight"
filled
label="Weight"
/>
<q-uploader
:url="api.getUploadUrl(gym)"
label="Upload video"
field-name="file"
max-file-size="1000000000"
@uploaded="onUploadSuccess"
>
</q-uploader>
<div>
<q-btn label="Submit" type="submit" color="primary"/>
<q-btn label="Reset" type="reset" color="primary" flat class="q-ml-sm"/>
</div>
</q-form>
</div>
<p>All the rest of the gym leaderboards should show up here.</p>
<video v-if="videoRef" :src="api.getFileUrl(videoRef)"> </video>
</q-page>
<q-page v-if="notFound">
<h3>Gym not found! Oh no!!!</h3>
<router-link to="/">Back</router-link>
</q-page>
</template>
<script setup lang="ts">
import { onMounted, ref, Ref } from 'vue';
import * as api from 'src/api/gymboard-api';
import { useRoute } from 'vue-router';
const route = useRoute();
const gym: Ref<api.Gym | undefined> = ref<api.Gym>();
const notFound: Ref<boolean | undefined> = ref<boolean>();
const videoRef: Ref<number | undefined> = ref<number>();
let submissionModel = {
name: '',
exercise: '',
weight: 0
};
// Once the component is mounted, load the gym that we're at.
onMounted(async () => {
try {
gym.value = await api.getGym(
route.params.countryCode as string,
route.params.cityShortName as string,
route.params.gymShortName as string
);
notFound.value = false;
} catch (error) {
console.error(error);
notFound.value = true;
}
});
function onSubmit() {
console.log('submitting!');
}
function onReset() {
submissionModel.name = '';
submissionModel.exercise = '';
submissionModel.weight = 0;
}
function onUploadSuccess(info: any) {
console.log(info);
const fileId: number = JSON.parse(info.xhr.response).id;
videoRef.value = fileId;
}
</script>

View File

@ -13,18 +13,22 @@
</template> </template>
</q-input> </q-input>
<q-list> <q-list>
<GymSearchResultListItem v-for="result in searchResults" :gym="result" :key="result.compoundId" /> <GymSearchResultListItem
v-for="result in searchResults"
:gym="result"
:key="result.compoundId"
/>
</q-list> </q-list>
</StandardCenteredPage> </StandardCenteredPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {onMounted, ref, Ref} from 'vue'; import { onMounted, ref, Ref } from 'vue';
import {useRoute, useRouter} from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import GymSearchResultListItem from 'components/GymSearchResultListItem.vue'; import GymSearchResultListItem from 'components/GymSearchResultListItem.vue';
import StandardCenteredPage from 'src/components/StandardCenteredPage.vue'; import StandardCenteredPage from 'src/components/StandardCenteredPage.vue';
import {GymSearchResult} from 'src/api/search/models'; import { GymSearchResult } from 'src/api/search/models';
import {searchGyms} from 'src/api/search'; import { searchGyms } from 'src/api/search';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@ -68,7 +72,7 @@ async function doSearch() {
} }
await router.push({ path: '/', query: query }); await router.push({ path: '/', query: query });
try { try {
searchResults.value = await searchGyms(searchQueryText) searchResults.value = await searchGyms(searchQueryText);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} finally { } finally {

View File

@ -1,21 +1,12 @@
<template> <template>
<q-page> <q-page>
<h3>Gym Home Page</h3> <h3>Gym Home Page</h3>
<p> <p>Maybe put an image of the gym here?</p>
Maybe put an image of the gym here? <p>Put a description of the gym here?</p>
</p> <p>Maybe show a snapshot of some recent lifts?</p>
<p>
Put a description of the gym here?
</p>
<p>
Maybe show a snapshot of some recent lifts?
</p>
</q-page> </q-page>
</template> </template>
<script setup lang="ts"> <script setup lang="ts"></script>
</script>
<style scoped> <style scoped></style>
</style>

View File

@ -1,15 +1,10 @@
<template> <template>
<q-page> <q-page>
<h3>Leaderboards</h3> <h3>Leaderboards</h3>
<p> <p>Some text here.</p>
Some text here.
</p>
</q-page> </q-page>
</template> </template>
<script setup lang="ts"> <script setup lang="ts"></script>
</script>
<style scoped> <style scoped></style>
</style>

View File

@ -18,16 +18,16 @@
:color="leaderboardPageSelected ? 'primary' : 'secondary'" :color="leaderboardPageSelected ? 'primary' : 'secondary'"
/> />
</q-btn-group> </q-btn-group>
<router-view/> <router-view />
</StandardCenteredPage> </StandardCenteredPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, onMounted, ref, Ref} from 'vue'; import { computed, onMounted, ref, Ref } from 'vue';
import { getGym, Gym } from 'src/api/gymboard-api'; import { useRoute, useRouter } from 'vue-router';
import {useRoute, useRouter} from 'vue-router';
import StandardCenteredPage from 'components/StandardCenteredPage.vue'; import StandardCenteredPage from 'components/StandardCenteredPage.vue';
import {getGymRoute} from 'src/router/gym-routing'; import { getGymFromRoute, getGymRoute } from 'src/router/gym-routing';
import { Gym } from 'src/api/main/gyms';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@ -37,18 +37,20 @@ const gym: Ref<Gym | undefined> = ref<Gym>();
// Once the component is mounted, load the gym that we're at. // Once the component is mounted, load the gym that we're at.
onMounted(async () => { onMounted(async () => {
try { try {
gym.value = await getGym( gym.value = await getGymFromRoute();
route.params.countryCode as string,
route.params.cityShortName as string,
route.params.gymShortName as string
);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
await router.push('/'); await router.push('/');
} }
}); });
const homePageSelected = computed(() => gym.value && getGymRoute(gym.value) === route.fullPath); const homePageSelected = computed(
const submitPageSelected = computed(() => gym.value && route.fullPath === getGymRoute(gym.value) + '/submit'); () => gym.value && getGymRoute(gym.value) === route.fullPath
const leaderboardPageSelected = computed(() => gym.value && route.fullPath === getGymRoute(gym.value) + '/leaderboard'); );
const submitPageSelected = computed(
() => gym.value && route.fullPath === getGymRoute(gym.value) + '/submit'
);
const leaderboardPageSelected = computed(
() => gym.value && route.fullPath === getGymRoute(gym.value) + '/leaderboard'
);
</script> </script>

View File

@ -8,7 +8,7 @@ A high-level overview of the submission process is as follows:
2. The user submits their lift's JSON data, including the `videoId`. 2. The user submits their lift's JSON data, including the `videoId`.
3. The API responds (if the data is valid) with the created submission, with the status WAITING. 3. The API responds (if the data is valid) with the created submission, with the status WAITING.
4. Eventually the API will process the submission and status will change to either COMPLETED or FAILED. 4. Eventually the API will process the submission and status will change to either COMPLETED or FAILED.
5. We wait on the submission page until the submission is done processing, then show a message and navigate to the submission page.
--> -->
<template> <template>
<q-page v-if="gym"> <q-page v-if="gym">
@ -61,15 +61,17 @@ A high-level overview of the submission process is as follows:
/> />
</div> </div>
<div class="row"> <div class="row">
<q-uploader <q-file
id="uploader" v-model="selectedVideoFile"
:url="getUploadUrl(gym)"
:label="$t('gymPage.submitPage.upload')" :label="$t('gymPage.submitPage.upload')"
field-name="file"
@uploaded="onFileUploaded"
max-file-size="1000000000" max-file-size="1000000000"
class="col-12 q-mt-md" accept="video/*"
/> class="col-12"
>
<template v-slot:prepend>
<q-icon name="attach_file" />
</template>
</q-file>
</div> </div>
<div class="row"> <div class="row">
<q-btn <q-btn
@ -77,6 +79,7 @@ A high-level overview of the submission process is as follows:
color="primary" color="primary"
type="submit" type="submit"
class="q-mt-md col-12" class="q-mt-md col-12"
:disable="!submitButtonEnabled()"
/> />
</div> </div>
</SlimForm> </SlimForm>
@ -85,22 +88,16 @@ A high-level overview of the submission process is as follows:
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {onMounted, ref, Ref} from 'vue'; import { onMounted, ref, Ref } from 'vue';
import { import { getGymFromRoute } from 'src/router/gym-routing';
createSubmission,
Exercise,
ExerciseSubmissionPayload,
getExercises,
getUploadUrl,
Gym
} from 'src/api/gymboard-api';
import {getGymFromRoute} from 'src/router/gym-routing';
import SlimForm from 'components/SlimForm.vue'; import SlimForm from 'components/SlimForm.vue';
import {QUploader} from "quasar"; import api from 'src/api/main';
import { Gym } from 'src/api/main/gyms';
import { Exercise } from 'src/api/main/exercises';
interface Option { interface Option {
value: string, value: string;
label: string label: string;
} }
const gym: Ref<Gym | undefined> = ref<Gym>(); const gym: Ref<Gym | undefined> = ref<Gym>();
@ -114,9 +111,10 @@ let submissionModel = ref({
reps: 1, reps: 1,
videoId: -1, videoId: -1,
videoFile: null, videoFile: null,
date: new Date().toLocaleDateString('en-CA') date: new Date().toLocaleDateString('en-CA'),
}); });
const weightUnits = ['Kg', 'Lbs']; const selectedVideoFile: Ref<File | undefined> = ref<File>();
const weightUnits = ['KG', 'LBS'];
// TODO: Make it possible to pass the gym to this via props instead. // TODO: Make it possible to pass the gym to this via props instead.
onMounted(async () => { onMounted(async () => {
@ -126,29 +124,40 @@ onMounted(async () => {
console.error(error); console.error(error);
} }
try { try {
exercises.value = await getExercises(); exercises.value = await api.exercises.getExercises();
exerciseOptions.value = exercises.value.map(exercise => { exerciseOptions.value = exercises.value.map((exercise) => {
return {value: exercise.shortName, label: exercise.displayName} return { value: exercise.shortName, label: exercise.displayName };
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
}); });
function onFileUploaded(info: {files: Array<never>, xhr: XMLHttpRequest}) { function submitButtonEnabled() {
const responseData = JSON.parse(info.xhr.responseText); return selectedVideoFile.value !== undefined && validateForm();
submissionModel.value.videoId = responseData.id;
} }
function onSubmitted() { function validateForm() {
console.log('submitted'); return true;
if (gym.value) { }
const submission = createSubmission(gym.value, submissionModel.value);
console.log(submission); async function onSubmitted() {
} if (!selectedVideoFile.value || !gym.value) throw new Error('Invalid state.');
submissionModel.value.videoId = await api.gyms.submissions.uploadVideoFile(
gym.value,
selectedVideoFile.value
);
const submission = await api.gyms.submissions.createSubmission(
gym.value,
submissionModel.value
);
const completedSubmission =
await api.gyms.submissions.waitUntilSubmissionProcessed(
gym.value,
submission.id
);
console.log(completedSubmission);
} }
</script> </script>
<style scoped> <style scoped></style>
</style>

View File

@ -1,5 +1,6 @@
import {useRoute} from 'vue-router'; import { useRoute } from 'vue-router';
import {getGym, Gym} from 'src/api/gymboard-api'; import { Gym } from 'src/api/main/gyms';
import api from 'src/api/main';
/** /**
* Any object that contains the properties needed to identify a single gym. * Any object that contains the properties needed to identify a single gym.
@ -7,7 +8,7 @@ import {getGym, Gym} from 'src/api/gymboard-api';
export interface GymRoutable { export interface GymRoutable {
countryCode: string; countryCode: string;
cityShortName: string; cityShortName: string;
shortName: string shortName: string;
} }
/** /**
@ -23,7 +24,7 @@ export function getGymRoute(gym: GymRoutable): string {
*/ */
export async function getGymFromRoute(): Promise<Gym> { export async function getGymFromRoute(): Promise<Gym> {
const route = useRoute(); const route = useRoute();
return await getGym( return await api.gyms.getGym(
route.params.countryCode as string, route.params.countryCode as string,
route.params.cityShortName as string, route.params.cityShortName as string,
route.params.gymShortName as string route.params.gymShortName as string

View File

@ -20,7 +20,9 @@ import routes from './routes';
export default route(function (/* { store, ssrContext } */) { export default route(function (/* { store, ssrContext } */) {
const createHistory = process.env.SERVER const createHistory = process.env.SERVER
? createMemoryHistory ? createMemoryHistory
: (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory); : process.env.VUE_ROUTER_MODE === 'history'
? createWebHistory
: createWebHashHistory;
const Router = createRouter({ const Router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }), scrollBehavior: () => ({ left: 0, top: 0 }),

View File

@ -18,9 +18,9 @@ const routes: RouteRecordRaw[] = [
children: [ children: [
{ path: '', component: GymHomePage }, { path: '', component: GymHomePage },
{ path: 'submit', component: GymSubmissionPage }, { path: 'submit', component: GymSubmissionPage },
{ path: 'leaderboard', component: GymLeaderboardsPage } { path: 'leaderboard', component: GymLeaderboardsPage },
] ],
} },
], ],
}, },

View File

@ -1,5 +1,5 @@
import { store } from 'quasar/wrappers' import { store } from 'quasar/wrappers';
import { createPinia } from 'pinia' import { createPinia } from 'pinia';
import { Router } from 'vue-router'; import { Router } from 'vue-router';
/* /*
@ -23,10 +23,10 @@ declare module 'pinia' {
*/ */
export default store((/* { ssrContext } */) => { export default store((/* { ssrContext } */) => {
const pinia = createPinia() const pinia = createPinia();
// You can add Pinia plugins here // You can add Pinia plugins here
// pinia.use(SomePiniaPlugin) // pinia.use(SomePiniaPlugin)
return pinia return pinia;
}) });

View File

@ -1,9 +1,9 @@
/* eslint-disable */ /* eslint-disable */
// THIS FEATURE-FLAG FILE IS AUTOGENERATED, // THIS FEATURE-FLAG FILE IS AUTOGENERATED,
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING // REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
import "quasar/dist/types/feature-flag"; import 'quasar/dist/types/feature-flag';
declare module "quasar/dist/types/feature-flag" { declare module 'quasar/dist/types/feature-flag' {
interface QuasarFeatureFlags { interface QuasarFeatureFlags {
store: true; store: true;
} }

View File

@ -0,0 +1 @@
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

View File

@ -3,4 +3,4 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": "." "baseUrl": "."
} }
} }