Added better submission upload workflow.
This commit is contained in:
parent
ed3152aa2b
commit
3908c2becd
|
@ -8,12 +8,11 @@ import org.springframework.http.MediaType;
|
|||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Controller for accessing a particular gym.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(path = "/gyms/{compoundId}")
|
||||
public class GymController {
|
||||
private final GymService gymService;
|
||||
private final UploadService uploadService;
|
||||
|
@ -25,35 +24,32 @@ public class GymController {
|
|||
this.submissionService = submissionService;
|
||||
}
|
||||
|
||||
@GetMapping(path = "/gyms/{countryCode}/{cityCode}/{gymName}")
|
||||
public GymResponse getGym(
|
||||
@PathVariable String countryCode,
|
||||
@PathVariable String cityCode,
|
||||
@PathVariable String gymName
|
||||
) {
|
||||
return gymService.getGym(new RawGymId(countryCode, cityCode, gymName));
|
||||
@GetMapping
|
||||
public GymResponse getGym(@PathVariable String compoundId) {
|
||||
return gymService.getGym(CompoundGymId.parse(compoundId));
|
||||
}
|
||||
|
||||
@PostMapping(path = "/gyms/{countryCode}/{cityCode}/{gymName}/submissions")
|
||||
@PostMapping(path = "/submissions")
|
||||
public ExerciseSubmissionResponse createSubmission(
|
||||
@PathVariable String countryCode,
|
||||
@PathVariable String cityCode,
|
||||
@PathVariable String gymName,
|
||||
@PathVariable String compoundId,
|
||||
@RequestBody ExerciseSubmissionPayload payload
|
||||
) {
|
||||
return submissionService.createSubmission(new RawGymId(countryCode, cityCode, gymName), payload);
|
||||
return submissionService.createSubmission(CompoundGymId.parse(compoundId), payload);
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
path = "/gyms/{countryCode}/{cityCode}/{gymName}/submissions/upload",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE
|
||||
)
|
||||
@GetMapping(path = "/submissions/{submissionId}")
|
||||
public ExerciseSubmissionResponse getSubmission(
|
||||
@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(
|
||||
@PathVariable String countryCode,
|
||||
@PathVariable String cityCode,
|
||||
@PathVariable String gymName,
|
||||
@PathVariable String compoundId,
|
||||
@RequestParam MultipartFile file
|
||||
) {
|
||||
return uploadService.handleSubmissionUpload(new RawGymId(countryCode, cityCode, gymName), file);
|
||||
return uploadService.handleSubmissionUpload(CompoundGymId.parse(compoundId), 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]
|
||||
);
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ public record ExerciseSubmissionPayload(
|
|||
String name,
|
||||
String exerciseShortName,
|
||||
float weight,
|
||||
String weightUnit,
|
||||
int reps,
|
||||
long videoId
|
||||
) {}
|
||||
|
|
|
@ -11,7 +11,9 @@ public record ExerciseSubmissionResponse(
|
|||
ExerciseResponse exercise,
|
||||
String status,
|
||||
String submitterName,
|
||||
double weight,
|
||||
double rawWeight,
|
||||
String weightUnit,
|
||||
double metricWeight,
|
||||
int reps
|
||||
) {
|
||||
public ExerciseSubmissionResponse(ExerciseSubmission submission) {
|
||||
|
@ -22,7 +24,9 @@ public record ExerciseSubmissionResponse(
|
|||
new ExerciseResponse(submission.getExercise()),
|
||||
submission.getStatus().name(),
|
||||
submission.getSubmitterName(),
|
||||
submission.getWeight().doubleValue(),
|
||||
submission.getRawWeight().doubleValue(),
|
||||
submission.getWeightUnit().name(),
|
||||
submission.getMetricWeight().doubleValue(),
|
||||
submission.getReps()
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
package nl.andrewlalis.gymboard_api.controller.dto;
|
||||
|
||||
public record RawGymId(String countryCode, String cityCode, String gymName) {}
|
|
@ -1,5 +1,6 @@
|
|||
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.GymId;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
@ -12,8 +13,14 @@ import java.util.Optional;
|
|||
@Repository
|
||||
public interface GymRepository extends JpaRepository<Gym, GymId>, JpaSpecificationExecutor<Gym> {
|
||||
@Query("SELECT g FROM Gym g " +
|
||||
"WHERE g.id.shortName = :gym AND " +
|
||||
"g.id.city.id.shortName = :city AND " +
|
||||
"g.id.city.id.country.code = :country")
|
||||
Optional<Gym> findByRawId(String gym, String city, String country);
|
||||
"WHERE g.id.shortName = :#{#id.gym()} AND " +
|
||||
"g.id.city.id.shortName = :#{#id.city()} AND " +
|
||||
"g.id.city.id.country.code = :#{#id.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);
|
||||
}
|
||||
|
|
|
@ -13,4 +13,5 @@ import java.util.Optional;
|
|||
public interface ExerciseSubmissionTempFileRepository extends JpaRepository<ExerciseSubmissionTempFile, Long> {
|
||||
List<ExerciseSubmissionTempFile> findAllByCreatedAtBefore(LocalDateTime timestamp);
|
||||
Optional<ExerciseSubmissionTempFile> findBySubmission(ExerciseSubmission submission);
|
||||
boolean existsByPath(String path);
|
||||
}
|
||||
|
|
|
@ -28,6 +28,11 @@ public class ExerciseSubmission {
|
|||
VERIFIED
|
||||
}
|
||||
|
||||
public enum WeightUnit {
|
||||
KG,
|
||||
LBS
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
@ -49,18 +54,27 @@ public class ExerciseSubmission {
|
|||
private String submitterName;
|
||||
|
||||
@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)
|
||||
private int reps;
|
||||
|
||||
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.exercise = exercise;
|
||||
this.submitterName = submitterName;
|
||||
this.weight = weight;
|
||||
this.rawWeight = rawWeight;
|
||||
this.weightUnit = unit;
|
||||
this.metricWeight = metricWeight;
|
||||
this.reps = reps;
|
||||
this.status = Status.WAITING;
|
||||
}
|
||||
|
@ -93,8 +107,16 @@ public class ExerciseSubmission {
|
|||
return submitterName;
|
||||
}
|
||||
|
||||
public BigDecimal getWeight() {
|
||||
return weight;
|
||||
public BigDecimal getRawWeight() {
|
||||
return rawWeight;
|
||||
}
|
||||
|
||||
public WeightUnit getWeightUnit() {
|
||||
return weightUnit;
|
||||
}
|
||||
|
||||
public BigDecimal getMetricWeight() {
|
||||
return metricWeight;
|
||||
}
|
||||
|
||||
public int getReps() {
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
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.ExerciseSubmissionResponse;
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.RawGymId;
|
||||
import nl.andrewlalis.gymboard_api.dao.GymRepository;
|
||||
import nl.andrewlalis.gymboard_api.dao.StoredFileRepository;
|
||||
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository;
|
||||
|
@ -30,6 +30,7 @@ import java.nio.file.Files;
|
|||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
@ -63,6 +64,15 @@ public class ExerciseSubmissionService {
|
|||
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:
|
||||
|
@ -79,8 +89,8 @@ public class ExerciseSubmissionService {
|
|||
* @return The saved submission, which will be in the PROCESSING state at first.
|
||||
*/
|
||||
@Transactional
|
||||
public ExerciseSubmissionResponse createSubmission(RawGymId id, ExerciseSubmissionPayload payload) {
|
||||
Gym gym = gymRepository.findByRawId(id.gymName(), id.cityCode(), id.countryCode())
|
||||
public ExerciseSubmissionResponse createSubmission(CompoundGymId id, ExerciseSubmissionPayload payload) {
|
||||
Gym gym = gymRepository.findByCompoundId(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
Exercise exercise = exerciseRepository.findById(payload.exerciseShortName())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid exercise."));
|
||||
|
@ -90,11 +100,20 @@ public class ExerciseSubmissionService {
|
|||
// TODO: Validate the submission data.
|
||||
|
||||
// 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(
|
||||
gym,
|
||||
exercise,
|
||||
payload.name(),
|
||||
BigDecimal.valueOf(payload.weight()),
|
||||
rawWeight,
|
||||
unit,
|
||||
metricWeight,
|
||||
payload.reps()
|
||||
));
|
||||
// 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.
|
||||
Path dir = tempFilePath.getParent();
|
||||
Path dir = UploadService.SUBMISSION_TEMP_FILE_DIR;
|
||||
String tempFileName = tempFilePath.getFileName().toString();
|
||||
String tempFileBaseName = tempFileName.substring(0, tempFileName.length() - ".tmp".length());
|
||||
Path outFilePath = dir.resolve(tempFileBaseName + "-out.mp4");
|
||||
|
@ -255,4 +274,35 @@ public class ExerciseSubmissionService {
|
|||
Files.deleteIfExists(tmpStdout);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package nl.andrewlalis.gymboard_api.service;
|
||||
|
||||
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.model.Gym;
|
||||
import org.slf4j.Logger;
|
||||
|
@ -22,8 +22,8 @@ public class GymService {
|
|||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public GymResponse getGym(RawGymId id) {
|
||||
Gym gym = gymRepository.findByRawId(id.gymName(), id.cityCode(), id.countryCode())
|
||||
public GymResponse getGym(CompoundGymId id) {
|
||||
Gym gym = gymRepository.findByCompoundId(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
return new GymResponse(gym);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
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.dao.GymRepository;
|
||||
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseSubmissionTempFileRepository;
|
||||
|
@ -21,6 +21,7 @@ import java.nio.file.Path;
|
|||
*/
|
||||
@Service
|
||||
public class UploadService {
|
||||
public static final Path SUBMISSION_TEMP_FILE_DIR = Path.of("exercise_submission_temp_files");
|
||||
private static final String[] ALLOWED_VIDEO_TYPES = {
|
||||
"video/mp4"
|
||||
};
|
||||
|
@ -46,8 +47,8 @@ public class UploadService {
|
|||
* the user's submission.
|
||||
*/
|
||||
@Transactional
|
||||
public UploadedFileResponse handleSubmissionUpload(RawGymId gymId, MultipartFile multipartFile) {
|
||||
Gym gym = gymRepository.findByRawId(gymId.gymName(), gymId.cityCode(), gymId.countryCode())
|
||||
public UploadedFileResponse handleSubmissionUpload(CompoundGymId gymId, MultipartFile multipartFile) {
|
||||
Gym gym = gymRepository.findByCompoundId(gymId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
// TODO: Check that user is allowed to upload.
|
||||
boolean fileTypeAcceptable = false;
|
||||
|
@ -61,11 +62,10 @@ public class UploadService {
|
|||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid content type.");
|
||||
}
|
||||
try {
|
||||
Path tempFileDir = Path.of("exercise_submission_temp_files");
|
||||
if (!Files.exists(tempFileDir)) {
|
||||
Files.createDirectory(tempFileDir);
|
||||
if (!Files.exists(SUBMISSION_TEMP_FILE_DIR)) {
|
||||
Files.createDirectory(SUBMISSION_TEMP_FILE_DIR);
|
||||
}
|
||||
Path tempFilePath = Files.createTempFile(tempFileDir, null, null);
|
||||
Path tempFilePath = Files.createTempFile(SUBMISSION_TEMP_FILE_DIR, null, null);
|
||||
multipartFile.transferTo(tempFilePath);
|
||||
ExerciseSubmissionTempFile tempFileEntity = tempFileRepository.save(new ExerciseSubmissionTempFile(tempFilePath.toString()));
|
||||
return new UploadedFileResponse(tempFileEntity.getId());
|
||||
|
|
|
@ -9,14 +9,14 @@ module.exports = {
|
|||
// `parser: 'vue-eslint-parser'` is already included with any 'plugin:vue/**' config and should be omitted
|
||||
parserOptions: {
|
||||
parser: require.resolve('@typescript-eslint/parser'),
|
||||
extraFileExtensions: [ '.vue' ]
|
||||
extraFileExtensions: ['.vue'],
|
||||
},
|
||||
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
'vue/setup-compiler-macros': true
|
||||
'vue/setup-compiler-macros': true,
|
||||
},
|
||||
|
||||
// Rules order is important, please avoid shuffling them
|
||||
|
@ -37,7 +37,7 @@ module.exports = {
|
|||
|
||||
// https://github.com/prettier/eslint-config-prettier#installation
|
||||
// usage with Prettier, provided by 'eslint-config-prettier'.
|
||||
'prettier'
|
||||
'prettier',
|
||||
],
|
||||
|
||||
plugins: [
|
||||
|
@ -46,12 +46,11 @@ module.exports = {
|
|||
|
||||
// https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-files
|
||||
// required to lint *.vue files
|
||||
'vue'
|
||||
'vue',
|
||||
|
||||
// https://github.com/typescript-eslint/typescript-eslint/issues/389#issuecomment-509292674
|
||||
// Prettier has not been included as plugin to avoid performance impact
|
||||
// add it as an extension for your IDE
|
||||
|
||||
],
|
||||
|
||||
globals: {
|
||||
|
@ -64,12 +63,11 @@ module.exports = {
|
|||
__QUASAR_SSR_PWA__: 'readonly',
|
||||
process: 'readonly',
|
||||
Capacitor: 'readonly',
|
||||
chrome: 'readonly'
|
||||
chrome: 'readonly',
|
||||
},
|
||||
|
||||
// add your custom rules here
|
||||
rules: {
|
||||
|
||||
'prefer-promise-reject-errors': 'off',
|
||||
|
||||
quotes: ['warn', 'single', { avoidEscape: true }],
|
||||
|
@ -85,6 +83,6 @@ module.exports = {
|
|||
'no-unused-vars': 'off',
|
||||
|
||||
// allow debugger during development only
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
|
||||
}
|
||||
}
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -3,14 +3,7 @@
|
|||
"editor.guides.bracketPairs": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": [
|
||||
"source.fixAll.eslint"
|
||||
],
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"vue"
|
||||
],
|
||||
"editor.codeActionsOnSave": ["source.fixAll.eslint"],
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"],
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
|
@ -3,6 +3,7 @@
|
|||
Web app for Gymboard
|
||||
|
||||
## Install the dependencies
|
||||
|
||||
```bash
|
||||
yarn
|
||||
# or
|
||||
|
@ -10,32 +11,33 @@ npm install
|
|||
```
|
||||
|
||||
### Start the app in development mode (hot-code reloading, error reporting, etc.)
|
||||
|
||||
```bash
|
||||
quasar dev
|
||||
```
|
||||
|
||||
|
||||
### Lint the files
|
||||
|
||||
```bash
|
||||
yarn lint
|
||||
# or
|
||||
npm run lint
|
||||
```
|
||||
|
||||
|
||||
### Format the files
|
||||
|
||||
```bash
|
||||
yarn format
|
||||
# or
|
||||
npm run format
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Build the app for production
|
||||
|
||||
```bash
|
||||
quasar build
|
||||
```
|
||||
|
||||
### Customize the configuration
|
||||
|
||||
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).
|
||||
|
|
|
@ -3,17 +3,40 @@
|
|||
<head>
|
||||
<title><%= productName %></title>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta name="description" content="<%= productDescription %>">
|
||||
<meta name="format-detection" content="telephone=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 charset="utf-8" />
|
||||
<meta name="description" content="<%= productDescription %>" />
|
||||
<meta name="format-detection" content="telephone=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<% } %>"
|
||||
/>
|
||||
|
||||
<link rel="icon" type="image/png" sizes="128x128" 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">
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="128x128"
|
||||
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>
|
||||
<body>
|
||||
<!-- quasar:entry-point -->
|
||||
|
|
|
@ -13,9 +13,9 @@ module.exports = {
|
|||
'last 4 Android versions',
|
||||
'last 4 ChromeAndroid versions',
|
||||
'last 4 FirefoxAndroid versions',
|
||||
'last 4 iOS versions'
|
||||
]
|
||||
})
|
||||
'last 4 iOS versions',
|
||||
],
|
||||
}),
|
||||
|
||||
// https://github.com/elchininet/postcss-rtlcss
|
||||
// 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
|
||||
// 3. uncomment the following line:
|
||||
// require('postcss-rtlcss')
|
||||
]
|
||||
}
|
||||
],
|
||||
};
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
// Configuration for your app
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
|
||||
|
||||
|
||||
const { configure } = require('quasar/wrappers');
|
||||
const path = require('path');
|
||||
const { withCtx } = require('vue');
|
||||
|
@ -21,7 +20,7 @@ module.exports = configure(function (ctx) {
|
|||
// exclude = [],
|
||||
// rawOptions = {},
|
||||
warnings: true,
|
||||
errors: true
|
||||
errors: true,
|
||||
},
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
|
||||
|
@ -30,15 +29,10 @@ module.exports = configure(function (ctx) {
|
|||
// app boot file (/src/boot)
|
||||
// --> boot files are part of "main.js"
|
||||
// https://v2.quasar.dev/quasar-cli-vite/boot-files
|
||||
boot: [
|
||||
'i18n',
|
||||
'axios',
|
||||
],
|
||||
boot: ['i18n', 'axios'],
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
|
||||
css: [
|
||||
'app.scss'
|
||||
],
|
||||
css: ['app.scss'],
|
||||
|
||||
// https://github.com/quasarframework/quasar/tree/dev/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
|
||||
build: {
|
||||
target: {
|
||||
browser: [ 'es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1' ],
|
||||
node: 'node16'
|
||||
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
|
||||
node: 'node16',
|
||||
},
|
||||
|
||||
vueRouterMode: 'hash', // available values: 'hash', 'history'
|
||||
|
@ -71,7 +65,7 @@ module.exports = configure(function (ctx) {
|
|||
// publicPath: '/',
|
||||
// analyze: true,
|
||||
env: {
|
||||
API: ctx.dev ? 'http://localhost:8080' : 'https://api.gymboard.com'
|
||||
API: ctx.dev ? 'http://localhost:8080' : 'https://api.gymboard.com',
|
||||
},
|
||||
// rawDefine: {}
|
||||
// ignorePublicFolder: true,
|
||||
|
@ -83,20 +77,23 @@ module.exports = configure(function (ctx) {
|
|||
// viteVuePluginOptions: {},
|
||||
|
||||
vitePlugins: [
|
||||
['@intlify/vite-plugin-vue-i18n', {
|
||||
[
|
||||
'@intlify/vite-plugin-vue-i18n',
|
||||
{
|
||||
// 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 !
|
||||
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
|
||||
devServer: {
|
||||
// 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
|
||||
|
@ -114,7 +111,7 @@ module.exports = configure(function (ctx) {
|
|||
// directives: [],
|
||||
|
||||
// Quasar plugins
|
||||
plugins: []
|
||||
plugins: [],
|
||||
},
|
||||
|
||||
// animations: 'all', // --- includes all animations
|
||||
|
@ -150,8 +147,8 @@ module.exports = configure(function (ctx) {
|
|||
// (gets superseded if process.env.PORT is specified at runtime)
|
||||
|
||||
middlewares: [
|
||||
'render' // keep this as last one
|
||||
]
|
||||
'render', // keep this as last one
|
||||
],
|
||||
},
|
||||
|
||||
// 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
|
||||
capacitor: {
|
||||
hideSplashscreen: true
|
||||
hideSplashscreen: true,
|
||||
},
|
||||
|
||||
// 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: {
|
||||
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
|
||||
|
||||
// OS X / Mac App Store
|
||||
// appBundleId: '',
|
||||
// appCategoryType: '',
|
||||
// osxSign: '',
|
||||
// protocol: 'myapp://path',
|
||||
|
||||
// Windows only
|
||||
// win32metadata: { ... }
|
||||
},
|
||||
|
@ -203,18 +198,16 @@ module.exports = configure(function (ctx) {
|
|||
builder: {
|
||||
// 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
|
||||
bex: {
|
||||
contentScripts: [
|
||||
'my-content-script'
|
||||
],
|
||||
contentScripts: ['my-content-script'],
|
||||
|
||||
// extendBexScriptsConf (esbuildConf) {}
|
||||
// extendBexManifestJson (json) {}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
@ -6,6 +6,6 @@
|
|||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'App'
|
||||
name: 'App',
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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();
|
|
@ -0,0 +1,4 @@
|
|||
export interface GeoPoint {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
|
@ -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;
|
|
@ -1,15 +1,17 @@
|
|||
import axios from 'axios';
|
||||
import {GymSearchResult} from 'src/api/search/models';
|
||||
import { GymSearchResult } from 'src/api/search/models';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: 'http://localhost:8081'
|
||||
baseURL: 'http://localhost:8081',
|
||||
});
|
||||
|
||||
/**
|
||||
* Searches for gyms using the given query, and eventually returns results.
|
||||
* @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);
|
||||
return response.data;
|
||||
}
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
export interface GymSearchResult {
|
||||
compoundId: string,
|
||||
shortName: string,
|
||||
displayName: string,
|
||||
cityShortName: string,
|
||||
cityName: string,
|
||||
countryCode: string,
|
||||
countryName: string,
|
||||
streetAddress: string,
|
||||
latitude: number,
|
||||
longitude: number
|
||||
compoundId: string;
|
||||
shortName: string;
|
||||
displayName: string;
|
||||
cityShortName: string;
|
||||
cityName: string;
|
||||
countryCode: string;
|
||||
countryName: string;
|
||||
streetAddress: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import messages from 'src/i18n';
|
|||
|
||||
export type MessageLanguages = keyof typeof messages;
|
||||
// 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
|
||||
/* eslint-disable @typescript-eslint/no-empty-interface */
|
||||
|
|
|
@ -1,14 +1,6 @@
|
|||
<template>
|
||||
<q-item
|
||||
clickable
|
||||
tag="a"
|
||||
target="_blank"
|
||||
:href="link"
|
||||
>
|
||||
<q-item-section
|
||||
v-if="icon"
|
||||
avatar
|
||||
>
|
||||
<q-item clickable tag="a" target="_blank" :href="link">
|
||||
<q-item-section v-if="icon" avatar>
|
||||
<q-icon :name="icon" />
|
||||
</q-item-section>
|
||||
|
||||
|
@ -27,23 +19,23 @@ export default defineComponent({
|
|||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
|
||||
caption: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '',
|
||||
},
|
||||
|
||||
link: {
|
||||
type: String,
|
||||
default: '#'
|
||||
default: '#',
|
||||
},
|
||||
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
}
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -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>
|
|
@ -6,21 +6,19 @@
|
|||
<q-item-label caption lines="1">{{ gym.countryName }}</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side top>
|
||||
<q-badge color="primary" label="10k"/>
|
||||
<q-badge color="primary" label="10k" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {getGymRoute} from 'src/router/gym-routing';
|
||||
import {GymSearchResult} from 'src/api/search/models';
|
||||
import { getGymRoute } from 'src/router/gym-routing';
|
||||
import { GymSearchResult } from 'src/api/search/models';
|
||||
|
||||
interface Props {
|
||||
gym: GymSearchResult
|
||||
gym: GymSearchResult;
|
||||
}
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
export interface Todo {
|
||||
id: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface Meta {
|
||||
totalCount: number;
|
||||
}
|
|
@ -12,14 +12,14 @@
|
|||
// to match your app's branding.
|
||||
// Tip: Use the "Theme Builder" on Quasar's documentation website.
|
||||
|
||||
$primary : #171717;
|
||||
$secondary : #575757;
|
||||
$accent : #b60600;
|
||||
$primary: #171717;
|
||||
$secondary: #575757;
|
||||
$accent: #b60600;
|
||||
|
||||
$dark : #1D1D1D;
|
||||
$dark-page : #121212;
|
||||
$dark: #1d1d1d;
|
||||
$dark-page: #121212;
|
||||
|
||||
$positive : #41b65c;
|
||||
$negative : #c0293b;
|
||||
$info : #8bcfdd;
|
||||
$warning : #eccb70;
|
||||
$positive: #41b65c;
|
||||
$negative: #c0293b;
|
||||
$info: #8bcfdd;
|
||||
$warning: #eccb70;
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
export default {
|
||||
mainLayout: {
|
||||
language: 'Language',
|
||||
pages: 'Pages'
|
||||
pages: 'Pages',
|
||||
},
|
||||
indexPage: {
|
||||
searchHint: 'Search for a Gym'
|
||||
searchHint: 'Search for a Gym',
|
||||
},
|
||||
gymPage: {
|
||||
home: 'Home',
|
||||
|
@ -15,7 +15,8 @@ export default {
|
|||
weight: 'Weight',
|
||||
reps: 'Repetitions',
|
||||
date: 'Date',
|
||||
submit: 'Submit'
|
||||
}
|
||||
}
|
||||
upload: 'Video File to Upload',
|
||||
submit: 'Submit',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -3,5 +3,5 @@ import nlNL from './nl-NL';
|
|||
|
||||
export default {
|
||||
'en-US': enUS,
|
||||
'nl-NL': nlNL
|
||||
'nl-NL': nlNL,
|
||||
};
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
export default {
|
||||
mainLayout: {
|
||||
language: 'Taal',
|
||||
pages: 'Pagina\'s'
|
||||
pages: "Pagina's",
|
||||
},
|
||||
indexPage: {
|
||||
searchHint: 'Zoek een sportschool'
|
||||
searchHint: 'Zoek een sportschool',
|
||||
},
|
||||
gymPage: {
|
||||
home: 'Thuis',
|
||||
|
@ -15,7 +15,8 @@ export default {
|
|||
weight: 'Gewicht',
|
||||
reps: 'Repetities',
|
||||
date: 'Datum',
|
||||
submit: 'Sturen'
|
||||
}
|
||||
}
|
||||
}
|
||||
upload: 'Videobestand om te uploaden',
|
||||
submit: 'Sturen',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -12,7 +12,9 @@
|
|||
/>
|
||||
|
||||
<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-select
|
||||
v-model="i18n.locale.value"
|
||||
|
@ -34,15 +36,9 @@
|
|||
</q-toolbar>
|
||||
</q-header>
|
||||
|
||||
<q-drawer
|
||||
v-model="leftDrawerOpen"
|
||||
show-if-above
|
||||
bordered
|
||||
>
|
||||
<q-drawer v-model="leftDrawerOpen" show-if-above bordered>
|
||||
<q-list>
|
||||
<q-item-label
|
||||
header
|
||||
>
|
||||
<q-item-label header>
|
||||
{{ $t('mainLayout.pages') }}
|
||||
</q-item-label>
|
||||
<q-item clickable>Gyms</q-item>
|
||||
|
@ -58,17 +54,17 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
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 = [
|
||||
{ value: 'en-US', label: 'English' },
|
||||
{ value: 'nl-NL', label: 'Nederlands' }
|
||||
{ value: 'nl-NL', label: 'Nederlands' },
|
||||
];
|
||||
|
||||
const leftDrawerOpen = ref(false);
|
||||
|
||||
function toggleLeftDrawer () {
|
||||
leftDrawerOpen.value = !leftDrawerOpen.value
|
||||
function toggleLeftDrawer() {
|
||||
leftDrawerOpen.value = !leftDrawerOpen.value;
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
<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 style="font-size: 30vh">
|
||||
404
|
||||
</div>
|
||||
<div style="font-size: 30vh">404</div>
|
||||
|
||||
<div class="text-h2" style="opacity:.4">
|
||||
Oops. Nothing here...
|
||||
</div>
|
||||
<div class="text-h2" style="opacity: 0.4">Oops. Nothing here...</div>
|
||||
|
||||
<q-btn
|
||||
class="q-mt-xl"
|
||||
|
@ -26,6 +24,6 @@
|
|||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ErrorNotFound'
|
||||
name: 'ErrorNotFound',
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -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>
|
|
@ -13,18 +13,22 @@
|
|||
</template>
|
||||
</q-input>
|
||||
<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>
|
||||
</StandardCenteredPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {onMounted, ref, Ref} from 'vue';
|
||||
import {useRoute, useRouter} from 'vue-router';
|
||||
import { onMounted, ref, Ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import GymSearchResultListItem from 'components/GymSearchResultListItem.vue';
|
||||
import StandardCenteredPage from 'src/components/StandardCenteredPage.vue';
|
||||
import {GymSearchResult} from 'src/api/search/models';
|
||||
import {searchGyms} from 'src/api/search';
|
||||
import { GymSearchResult } from 'src/api/search/models';
|
||||
import { searchGyms } from 'src/api/search';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
@ -68,7 +72,7 @@ async function doSearch() {
|
|||
}
|
||||
await router.push({ path: '/', query: query });
|
||||
try {
|
||||
searchResults.value = await searchGyms(searchQueryText)
|
||||
searchResults.value = await searchGyms(searchQueryText);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
|
|
|
@ -1,21 +1,12 @@
|
|||
<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>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
|
|
@ -1,15 +1,10 @@
|
|||
<template>
|
||||
<q-page>
|
||||
<h3>Leaderboards</h3>
|
||||
<p>
|
||||
Some text here.
|
||||
</p>
|
||||
<p>Some text here.</p>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
|
|
@ -18,16 +18,16 @@
|
|||
:color="leaderboardPageSelected ? 'primary' : 'secondary'"
|
||||
/>
|
||||
</q-btn-group>
|
||||
<router-view/>
|
||||
<router-view />
|
||||
</StandardCenteredPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, ref, Ref} from 'vue';
|
||||
import { getGym, Gym } from 'src/api/gymboard-api';
|
||||
import {useRoute, useRouter} from 'vue-router';
|
||||
import { computed, onMounted, ref, Ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
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 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.
|
||||
onMounted(async () => {
|
||||
try {
|
||||
gym.value = await getGym(
|
||||
route.params.countryCode as string,
|
||||
route.params.cityShortName as string,
|
||||
route.params.gymShortName as string
|
||||
);
|
||||
gym.value = await getGymFromRoute();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await router.push('/');
|
||||
}
|
||||
});
|
||||
|
||||
const homePageSelected = computed(() => gym.value && getGymRoute(gym.value) === route.fullPath);
|
||||
const submitPageSelected = computed(() => gym.value && route.fullPath === getGymRoute(gym.value) + '/submit');
|
||||
const leaderboardPageSelected = computed(() => gym.value && route.fullPath === getGymRoute(gym.value) + '/leaderboard');
|
||||
const homePageSelected = computed(
|
||||
() => gym.value && getGymRoute(gym.value) === route.fullPath
|
||||
);
|
||||
const submitPageSelected = computed(
|
||||
() => gym.value && route.fullPath === getGymRoute(gym.value) + '/submit'
|
||||
);
|
||||
const leaderboardPageSelected = computed(
|
||||
() => gym.value && route.fullPath === getGymRoute(gym.value) + '/leaderboard'
|
||||
);
|
||||
</script>
|
||||
|
|
|
@ -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`.
|
||||
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.
|
||||
|
||||
5. We wait on the submission page until the submission is done processing, then show a message and navigate to the submission page.
|
||||
-->
|
||||
<template>
|
||||
<q-page v-if="gym">
|
||||
|
@ -61,15 +61,17 @@ A high-level overview of the submission process is as follows:
|
|||
/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<q-uploader
|
||||
id="uploader"
|
||||
:url="getUploadUrl(gym)"
|
||||
<q-file
|
||||
v-model="selectedVideoFile"
|
||||
:label="$t('gymPage.submitPage.upload')"
|
||||
field-name="file"
|
||||
@uploaded="onFileUploaded"
|
||||
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 class="row">
|
||||
<q-btn
|
||||
|
@ -77,6 +79,7 @@ A high-level overview of the submission process is as follows:
|
|||
color="primary"
|
||||
type="submit"
|
||||
class="q-mt-md col-12"
|
||||
:disable="!submitButtonEnabled()"
|
||||
/>
|
||||
</div>
|
||||
</SlimForm>
|
||||
|
@ -85,22 +88,16 @@ A high-level overview of the submission process is as follows:
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {onMounted, ref, Ref} from 'vue';
|
||||
import {
|
||||
createSubmission,
|
||||
Exercise,
|
||||
ExerciseSubmissionPayload,
|
||||
getExercises,
|
||||
getUploadUrl,
|
||||
Gym
|
||||
} from 'src/api/gymboard-api';
|
||||
import {getGymFromRoute} from 'src/router/gym-routing';
|
||||
import { onMounted, ref, Ref } from 'vue';
|
||||
import { getGymFromRoute } from 'src/router/gym-routing';
|
||||
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 {
|
||||
value: string,
|
||||
label: string
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const gym: Ref<Gym | undefined> = ref<Gym>();
|
||||
|
@ -114,9 +111,10 @@ let submissionModel = ref({
|
|||
reps: 1,
|
||||
videoId: -1,
|
||||
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.
|
||||
onMounted(async () => {
|
||||
|
@ -126,29 +124,40 @@ onMounted(async () => {
|
|||
console.error(error);
|
||||
}
|
||||
try {
|
||||
exercises.value = await getExercises();
|
||||
exerciseOptions.value = exercises.value.map(exercise => {
|
||||
return {value: exercise.shortName, label: exercise.displayName}
|
||||
exercises.value = await api.exercises.getExercises();
|
||||
exerciseOptions.value = exercises.value.map((exercise) => {
|
||||
return { value: exercise.shortName, label: exercise.displayName };
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
function onFileUploaded(info: {files: Array<never>, xhr: XMLHttpRequest}) {
|
||||
const responseData = JSON.parse(info.xhr.responseText);
|
||||
submissionModel.value.videoId = responseData.id;
|
||||
function submitButtonEnabled() {
|
||||
return selectedVideoFile.value !== undefined && validateForm();
|
||||
}
|
||||
|
||||
function onSubmitted() {
|
||||
console.log('submitted');
|
||||
if (gym.value) {
|
||||
const submission = createSubmission(gym.value, submissionModel.value);
|
||||
console.log(submission);
|
||||
}
|
||||
function validateForm() {
|
||||
return true;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import {useRoute} from 'vue-router';
|
||||
import {getGym, Gym} from 'src/api/gymboard-api';
|
||||
import { useRoute } from 'vue-router';
|
||||
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.
|
||||
|
@ -7,7 +8,7 @@ import {getGym, Gym} from 'src/api/gymboard-api';
|
|||
export interface GymRoutable {
|
||||
countryCode: string;
|
||||
cityShortName: string;
|
||||
shortName: string
|
||||
shortName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -23,7 +24,7 @@ export function getGymRoute(gym: GymRoutable): string {
|
|||
*/
|
||||
export async function getGymFromRoute(): Promise<Gym> {
|
||||
const route = useRoute();
|
||||
return await getGym(
|
||||
return await api.gyms.getGym(
|
||||
route.params.countryCode as string,
|
||||
route.params.cityShortName as string,
|
||||
route.params.gymShortName as string
|
||||
|
|
|
@ -20,7 +20,9 @@ import routes from './routes';
|
|||
export default route(function (/* { store, ssrContext } */) {
|
||||
const createHistory = process.env.SERVER
|
||||
? createMemoryHistory
|
||||
: (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory);
|
||||
: process.env.VUE_ROUTER_MODE === 'history'
|
||||
? createWebHistory
|
||||
: createWebHashHistory;
|
||||
|
||||
const Router = createRouter({
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
|
|
|
@ -18,9 +18,9 @@ const routes: RouteRecordRaw[] = [
|
|||
children: [
|
||||
{ path: '', component: GymHomePage },
|
||||
{ path: 'submit', component: GymSubmissionPage },
|
||||
{ path: 'leaderboard', component: GymLeaderboardsPage }
|
||||
]
|
||||
}
|
||||
{ path: 'leaderboard', component: GymLeaderboardsPage },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { store } from 'quasar/wrappers'
|
||||
import { createPinia } from 'pinia'
|
||||
import { store } from 'quasar/wrappers';
|
||||
import { createPinia } from 'pinia';
|
||||
import { Router } from 'vue-router';
|
||||
|
||||
/*
|
||||
|
@ -23,10 +23,10 @@ declare module 'pinia' {
|
|||
*/
|
||||
|
||||
export default store((/* { ssrContext } */) => {
|
||||
const pinia = createPinia()
|
||||
const pinia = createPinia();
|
||||
|
||||
// You can add Pinia plugins here
|
||||
// pinia.use(SomePiniaPlugin)
|
||||
|
||||
return pinia
|
||||
})
|
||||
return pinia;
|
||||
});
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
/* eslint-disable */
|
||||
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
|
||||
// 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 {
|
||||
store: true;
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
Loading…
Reference in New Issue