Added better submission upload workflow.

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

View File

@ -8,12 +8,11 @@ import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.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);
}
}

View File

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

View File

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

View File

@ -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()
);
}

View File

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

View File

@ -1,5 +1,6 @@
package nl.andrewlalis.gymboard_api.dao;
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);
}

View File

@ -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);
}

View File

@ -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() {

View File

@ -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);
}
}
}

View File

@ -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);
}

View File

@ -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());

View File

@ -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',
},
};

View File

@ -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"
}

View File

@ -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).

View File

@ -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 -->

View File

@ -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')
]
}
],
};

View File

@ -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', {
// if you want to use Vue I18n Legacy API, you need to set `compositionOnly: false`
// compositionOnly: false,
[
'@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/**')
}]
]
// you need to set i18n resource including paths !
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
@ -136,7 +133,7 @@ module.exports = configure(function (ctx) {
// https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
ssr: {
// ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
// will mess up SSR
// will mess up SSR
// extendSSRWebserverConf (esbuildConf) {},
// extendPackageJson (json) {},
@ -147,11 +144,11 @@ module.exports = configure(function (ctx) {
// manualPostHydrationTrigger: true,
prodPort: 3000, // The default port that the production server should use
// (gets superseded if process.env.PORT is specified at runtime)
// (gets superseded if process.env.PORT is specified at runtime)
middlewares: [
'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) {}
}
}
},
};
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,17 @@
import axios from 'axios';
import {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;
}

View File

@ -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;
}

View File

@ -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 */

View File

@ -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>

View File

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

View File

@ -6,21 +6,19 @@
<q-item-label caption lines="1">{{ gym.countryName }}</q-item-label>
</q-item-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>

View File

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

View File

@ -12,14 +12,14 @@
// to match your app's branding.
// 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;

View File

@ -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',
},
},
};

View File

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

View File

@ -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',
},
},
};

View File

@ -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>

View File

@ -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>

View File

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

View File

@ -13,18 +13,22 @@
</template>
</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 {

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -8,7 +8,7 @@ A high-level overview of the submission process is as follows:
2. The user submits their lift's JSON data, including the `videoId`.
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>

View File

@ -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

View File

@ -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 }),

View File

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

View File

@ -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;
});

View File

@ -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;
}

View File

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