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.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 name,
|
||||||
String exerciseShortName,
|
String exerciseShortName,
|
||||||
float weight,
|
float weight,
|
||||||
|
String weightUnit,
|
||||||
int reps,
|
int reps,
|
||||||
long videoId
|
long videoId
|
||||||
) {}
|
) {}
|
||||||
|
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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());
|
||||||
|
|
|
@ -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',
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
|
@ -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).
|
||||||
|
|
|
@ -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 -->
|
||||||
|
|
|
@ -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')
|
||||||
]
|
],
|
||||||
}
|
};
|
||||||
|
|
|
@ -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: [
|
||||||
|
@ -58,7 +52,7 @@ module.exports = configure(function (ctx) {
|
||||||
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', {
|
[
|
||||||
|
'@intlify/vite-plugin-vue-i18n',
|
||||||
|
{
|
||||||
// if you want to use Vue I18n Legacy API, you need to set `compositionOnly: false`
|
// if you want to use Vue I18n Legacy API, you need to set `compositionOnly: false`
|
||||||
// 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
|
||||||
|
@ -150,8 +147,8 @@ module.exports = configure(function (ctx) {
|
||||||
// (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) {}
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,6 +6,6 @@
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'App'
|
name: 'App',
|
||||||
});
|
});
|
||||||
</script>
|
</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;
|
|
@ -2,14 +2,16 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 */
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
|
@ -16,11 +16,9 @@ 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>
|
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
export interface Todo {
|
|
||||||
id: number;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Meta {
|
|
||||||
totalCount: number;
|
|
||||||
}
|
|
|
@ -16,7 +16,7 @@ $primary : #171717;
|
||||||
$secondary: #575757;
|
$secondary: #575757;
|
||||||
$accent: #b60600;
|
$accent: #b60600;
|
||||||
|
|
||||||
$dark : #1D1D1D;
|
$dark: #1d1d1d;
|
||||||
$dark-page: #121212;
|
$dark-page: #121212;
|
||||||
|
|
||||||
$positive: #41b65c;
|
$positive: #41b65c;
|
||||||
|
|
|
@ -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',
|
||||||
}
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
@ -63,12 +59,12 @@ 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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,7 +13,11 @@
|
||||||
</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>
|
||||||
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
|
||||||
|
|
|
@ -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>
|
|
||||||
|
|
|
@ -24,10 +24,10 @@
|
||||||
|
|
||||||
<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>
|
||||||
|
|
|
@ -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>
|
||||||
|
@ -86,21 +89,15 @@ A high-level overview of the submission process is as follows:
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, Ref } from 'vue';
|
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 { 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>
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 }),
|
||||||
|
|
|
@ -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 },
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
})
|
});
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
Loading…
Reference in New Issue