nuxt demo

This commit is contained in:
Jake Potrebic 2021-01-20 19:36:18 -08:00
parent 45f5bd9460
commit e29de1f156
No known key found for this signature in database
GPG Key ID: 7C58557EC9C421F8
22 changed files with 10559 additions and 1 deletions

1
client/.env.example Normal file
View File

@ -0,0 +1 @@
BACKEND_URL="backendurl.com"

17
client/.eslintrc.js Normal file
View File

@ -0,0 +1,17 @@
module.exports = {
root: true,
env: {
browser: true,
node: true,
},
extends: ['@nuxtjs/eslint-config-typescript', 'prettier', 'prettier/vue', 'plugin:prettier/recommended', 'plugin:nuxt/recommended'],
plugins: ['prettier'],
// add your custom rules here
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-unused-vars': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
'vue/no-unused-components': 'warn',
},
};

90
client/.gitignore vendored Normal file
View File

@ -0,0 +1,90 @@
# Created by .ignore support plugin (hsz.mobi)
### Node template
# Logs
/logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# Nuxt generate
dist
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# IDE / Editor
.idea
# Service worker
sw.*
# macOS
.DS_Store
# Vim swap files
*.swp

6
client/.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"tabWidth": 4,
"semi": true,
"singleQuote": true,
"printWidth": 160
}

20
client/README.md Normal file
View File

@ -0,0 +1,20 @@
# hangar-client
## Build Setup
```bash
# install dependencies
$ yarn install
# serve with hot reload at localhost:3000
$ yarn dev
# build for production and launch server
$ yarn build
$ yarn start
# generate static project
$ yarn generate
```
For detailed explanation on how things work, check out [Nuxt.js docs](https://nuxtjs.org).

View File

@ -0,0 +1,4 @@
// Ref: https://github.com/nuxt-community/vuetify-module#customvariables
//
// The variables you want to modify
// $font-size-root: 20px;

View File

@ -0,0 +1,47 @@
<template>
<v-data-iterator :items="projects" :footer-props="{ itemsPerPageOptions: [5, 15, 25] }" :items-per-page="25">
<template #default="props">
<v-hover v-for="project in props.items" :key="project.id" v-slot="{ hover }" style="width: 100%; height: 78px" class="d-block mb-3">
<NuxtLink :to="`/${project.namespace.owner}/${project.namespace.slug}`">
<v-sheet :elevation="hover ? 24 : 0" height="100%" width="100%" tile color="accent" class="transition-swing grow-on-hover mb-3">
<v-row no-gutters>
<div class="flex-shrink-0">
<v-img :src="project.icon_url" :alt="project.name" width="60px" height="60px" class="my-2 ml-2"></v-img>
</div>
<div class="ml-2">
<span class="text-h6">{{ project.name }}</span>
<br />
<span class="text-subtitle-2">{{ project.description }}</span>
</div>
</v-row>
</v-sheet>
</NuxtLink>
</v-hover>
</template>
</v-data-iterator>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { Project } from 'hangar-api';
import { PropType } from 'vue';
@Component
export default class ProjectList extends Vue {
@Prop({ type: Array as PropType<Project[]>, required: true })
projects!: Project[];
}
</script>
<style lang="scss" scoped>
.grow-on-hover {
transition: all 0.2s ease-in-out;
}
.grow-on-hover:hover {
transform: scale(1.015);
}
a {
text-decoration: none;
}
</style>

View File

@ -0,0 +1,25 @@
<template>
<v-app>
<v-app-bar fixed app>
<v-toolbar-title v-text="title" />
<v-spacer />
</v-app-bar>
<v-main>
<v-container>
<nuxt />
</v-container>
</v-main>
<v-footer absolute app>
<span>&copy; {{ new Date().getFullYear() }}</span>
</v-footer>
</v-app>
</template>
<script>
import { Component, Vue } from 'nuxt-property-decorator';
@Component
export default class DefaultLayout extends Vue {
title = 'Hangar';
}
</script>

41
client/layouts/error.vue Normal file
View File

@ -0,0 +1,41 @@
<template>
<v-app dark>
<h1 v-if="error.statusCode === 404">
{{ pageNotFound }}
</h1>
<h1 v-else>
{{ otherError }}
</h1>
<NuxtLink to="/"> Home page </NuxtLink>
</v-app>
</template>
<script>
export default {
layout: 'empty',
props: {
error: {
type: Object,
default: null,
},
},
data() {
return {
pageNotFound: '404 Not Found',
otherError: 'An error occurred',
};
},
head() {
const title = this.error.statusCode === 404 ? this.pageNotFound : this.otherError;
return {
title,
};
},
};
</script>
<style scoped>
h1 {
font-size: 20px;
}
</style>

View File

@ -0,0 +1,8 @@
# MIDDLEWARE
**This directory is not required, you can delete it if you don't want to use it.**
This directory contains your application middleware.
Middleware let you define custom functions that can be run before rendering either a page or a group of pages.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing#middleware).

79
client/nuxt.config.js Normal file
View File

@ -0,0 +1,79 @@
import colors from 'vuetify/es5/util/colors';
require('dotenv').config();
export default {
// Global page headers: https://go.nuxtjs.dev/config-head
head: {
titleTemplate: '%s - hangar-client',
title: 'hangar-client',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ hid: 'description', name: 'description', content: '' },
],
link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
},
// Global CSS: https://go.nuxtjs.dev/config-css
css: [],
// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
plugins: ['~/plugins/api.ts'],
// Auto import components: https://go.nuxtjs.dev/config-components
components: true,
// Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules
buildModules: [
// https://go.nuxtjs.dev/typescript
'@nuxt/typescript-build',
// https://go.nuxtjs.dev/vuetify
'@nuxtjs/vuetify',
],
// Modules: https://go.nuxtjs.dev/config-modules
modules: [
// https://go.nuxtjs.dev/axios
'@nuxtjs/axios',
// https://go.nuxtjs.dev/pwa
'@nuxtjs/pwa',
'cookie-universal-nuxt',
],
// Axios module configuration: https://go.nuxtjs.dev/config-axios
axios: {
baseURL: process.env.BACKEND_URL,
},
// PWA module configuration: https://go.nuxtjs.dev/pwa
pwa: {
manifest: {
lang: 'en',
},
},
// Vuetify module configuration: https://go.nuxtjs.dev/config-vuetify
vuetify: {
customVariables: ['~/assets/variables.scss'],
theme: {
dark: true,
themes: {
dark: {
primary: colors.blue.darken2,
accent: colors.grey.darken3,
secondary: colors.amber.darken3,
info: colors.teal.lighten1,
warning: colors.amber.base,
error: colors.deepOrange.accent4,
success: colors.green.accent3,
},
},
},
},
// Build Configuration: https://go.nuxtjs.dev/config-build
build: {
transpile: ['vuex-module-decorators'],
},
};

51
client/package.json Normal file
View File

@ -0,0 +1,51 @@
{
"name": "hangar-client",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "nuxt-ts",
"build": "nuxt-ts build",
"start": "nuxt-ts start",
"generate": "nuxt-ts generate",
"lint:js": "eslint --ext \".js,.vue\" --ignore-path .gitignore .",
"lint": "yarn lint:js"
},
"lint-staged": {
"*.{js,vue}": "eslint"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"dependencies": {
"@nuxt/typescript-runtime": "^2.0.1",
"@nuxtjs/axios": "^5.12.5",
"@nuxtjs/pwa": "^3.3.4",
"cookie-universal-nuxt": "^2.1.4",
"core-js": "^3.8.2",
"nuxt": "^2.14.12",
"nuxt-property-decorator": "^2.9.1",
"vue": "^2.6.12",
"vuex": "^3.6.0"
},
"devDependencies": {
"@nuxt/types": "^2.14.12",
"@nuxt/typescript-build": "^2.0.4",
"@nuxtjs/eslint-config-typescript": "^5.0.0",
"@nuxtjs/eslint-module": "^3.0.2",
"@nuxtjs/vuetify": "^1.11.3",
"babel-eslint": "^10.1.0",
"eslint": "^7.18.0",
"eslint-config-prettier": "^7.1.0",
"eslint-plugin-nuxt": "^2.0.0",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-vue": "^7.4.1",
"husky": "^4.3.8",
"lint-staged": "^10.5.3",
"prettier": "^2.2.1",
"typescript": "^4.1.3",
"vuetify": "^2.4.2",
"webpack": "^5.16.0"
}
}

View File

@ -0,0 +1,20 @@
<template>
<div>{{ project.name }}</div>
</template>
<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator';
import { Context } from '@nuxt/types';
import { Project } from 'hangar-api';
@Component
export default class ProjectPage extends Vue {
project?: Project;
async asyncData({ $api, params }: Context): Promise<{ project: Project }> {
return { project: await $api.request<Project>(`projects/${params.author}/${params.slug}`) };
}
}
</script>
<style lang="scss" scoped></style>

22
client/pages/index.vue Normal file
View File

@ -0,0 +1,22 @@
<template>
<v-row justify="center" align="center">
<v-col cols="12" sm="8" md="6">
<ProjectList :projects="projects.result"></ProjectList>
</v-col>
</v-row>
</template>
<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator';
import { Context } from '@nuxt/types';
import { PaginatedProjectList } from 'hangar-api';
@Component
export default class Home extends Vue {
projects?: PaginatedProjectList;
async asyncData({ $api }: Context): Promise<{ projects: PaginatedProjectList }> {
return { projects: await $api.request<PaginatedProjectList>('projects', 'get', { limit: 25, offset: 0 }) };
}
}
</script>

139
client/plugins/api.ts Normal file
View File

@ -0,0 +1,139 @@
import { Context } from '@nuxt/types';
import { Inject } from '@nuxt/types/app';
import { AxiosPromise } from 'axios';
import { ApiSession } from 'hangar-api';
import { NuxtAxiosInstance } from '@nuxtjs/axios';
import { NuxtCookies } from 'cookie-universal-nuxt';
import { Store } from 'vuex';
import { ApiSessionType } from '~/types/enums';
const createApi = ($axios: NuxtAxiosInstance, $cookies: NuxtCookies, store: Store<any>) => {
class API {
getSession(): Promise<string> {
return new Promise((resolve, reject) => {
let session: ApiSession;
const date = new Date();
date.setTime(date.getTime() + 60000);
if (store.state.auth.authenticated) {
session = $cookies.get('api_session');
if (typeof session === 'undefined' || (!isNaN(new Date(session.expires).getTime()) && new Date(session.expires) < date)) {
return $axios
.post<object, ApiSession>('/api/v1/authenticate', {}, { headers: { 'Content-Type': 'application/json' } })
.then((data) => {
if (data.type !== 'user') {
reject(new Error('Expected user session from user authentication'));
} else {
$cookies.set('api_session', JSON.stringify(data), {
path: '/',
maxAge: 60 * 60 * 24 * 7,
});
store.commit('auth/SET_AUTHED', true);
resolve(data.session);
}
})
.catch((error) => {
reject(error);
});
} else {
resolve(session.session);
}
} else {
session = $cookies.get('public_api_session');
if (typeof session === 'undefined' || (!isNaN(new Date(session.expires).getTime()) && new Date(session.expires) < date)) {
$axios
.post<ApiSession>('/api/v1/authenticate', {}, { headers: { 'Content-Type': 'application/json' } })
.then(({ data }) => {
if (data.type !== ApiSessionType.PUBLIC) {
reject(new Error('Expected public session from public authentication'));
} else {
$cookies.set('public_api_session', JSON.stringify(data), {
path: '/',
maxAge: 60 * 60 * 24 * 7 * 4,
});
resolve(data.session);
}
})
.catch((error) => {
reject(error);
});
} else {
resolve(session.session);
}
}
});
}
invalidateSession(): void {
if (store.state.auth.authenticated) {
$cookies.remove('api_session', {
path: '/',
});
} else {
store.commit('auth/SET_AUTHED', false);
$cookies.remove('public_api_session', {
path: '/',
});
}
}
request<T>(url: string, method: 'get' | 'post' = 'get', data: object = {}): Promise<T> {
return new Promise<T>((resolve, reject) => {
return this.getSession().then((session) => {
return ($axios({
method,
url: '/api/v1/' + url,
headers: { Authorization: 'HangarApi session="' + session + '"' },
data,
}) as AxiosPromise<T>)
.then(({ data }) => resolve(data))
.catch((error) => {
if (error.response && (error.response.error === 'Api session expired' || error.response.error === 'Invalid session')) {
// This should never happen but just in case we catch it and invalidate the session to definitely get a new one
this.invalidateSession();
this.request<T>(url, method, data)
.then((data) => {
resolve(data);
})
.catch((error) => {
reject(error);
});
} else {
reject(error.response.statusText);
}
});
});
});
}
}
return new API();
};
type apiType = ReturnType<typeof createApi>;
declare module 'vue/types/vue' {
interface Vue {
$api: apiType;
}
}
declare module '@nuxt/types' {
interface NuxtAppOptions {
$api: apiType;
}
interface Context {
$api: apiType;
}
}
declare module 'vuex/types/index' {
interface Store<S> {
$api: apiType;
}
}
export default ({ $axios, app: { $cookies }, store }: Context, inject: Inject) => {
inject('api', createApi($axios, $cookies, store));
};

BIN
client/static/favicon.ico Normal file

Binary file not shown.

14
client/store/auth.ts Normal file
View File

@ -0,0 +1,14 @@
import { MutationTree } from 'vuex';
import { User } from 'hangar-api';
export const state = () => ({
authenticated: false,
user: (null as unknown) as User,
});
export type AuthState = ReturnType<typeof state>;
export const mutations: MutationTree<AuthState> = {
SET_USER: (state, user: User) => (state.user = user),
SET_AUTHED: (state, auth: boolean) => (state.authenticated = auth),
};

40
client/tsconfig.json Normal file
View File

@ -0,0 +1,40 @@
{
"compilerOptions": {
"target": "ES2018",
"module": "ESNext",
"moduleResolution": "Node",
"lib": [
"ESNext",
"ESNext.AsyncIterable",
"DOM"
],
"esModuleInterop": true,
"allowJs": true,
"sourceMap": true,
"strict": true,
"noEmit": true,
"experimentalDecorators": true,
"baseUrl": ".",
"importHelpers": true,
"paths": {
"~/*": [
"./*"
],
"@/*": [
"./*"
]
},
"types": [
"@nuxt/types",
"@types/node",
"@nuxtjs/axios",
"cookie-universal-nuxt",
"@nuxtjs/vuetify"
]
},
"exclude": [
"node_modules",
".nuxt",
"dist"
]
}

108
client/types/api.d.ts vendored Normal file
View File

@ -0,0 +1,108 @@
/* eslint-disable camelcase */
declare module 'hangar-api' {
import { ApiSessionType, ProjectCategory, RoleCategory, Visibility } from '~/types/enums';
interface Model {
id: number;
created_at: string;
}
interface Named {
name: string;
}
interface Color {
value: number;
hex: string;
}
interface TagColor {
background: string;
foreground: string;
}
interface Role {
value: string;
role_id: number;
category: RoleCategory;
permission: bigint; // TODO maybe?
title: string;
color: Color;
}
interface User extends Model, Named {
tagline: string | null;
joinDate: string;
roles: Role[];
}
interface ApiSession {
session: string;
expires: string;
type: ApiSessionType;
}
interface Pagination {
limit: number;
offset: number;
count: number;
}
interface ProjectNamespace {
owner: string;
slug: string;
}
interface ProjectStats {
views: number;
downloads: number;
recent_views: number;
recent_downloads: number;
stars: number;
waters: number;
}
interface UserActions {
starred: boolean;
watching: boolean;
}
interface ProjectSettings {
homepage: string | null;
issues: string | null;
sources: string | null;
support: string | null;
license: string | null;
forumSync: boolean;
}
interface PromotedVersionTag extends Named {
data: string;
displayData: string;
minecraft_version: string;
color: TagColor;
}
interface PromotedVersion {
version: string;
tags: PromotedVersionTag[];
}
interface Project extends Model, Named {
namespace: ProjectNamespace;
promoted_versions: PromotedVersion[];
stats: ProjectStats;
category: ProjectCategory;
description: string;
last_updated: Date;
visibility: Visibility;
user_actions: UserActions;
settings: ProjectSettings;
icon_url: string;
}
interface PaginatedProjectList {
pagination: Pagination;
result: Project[];
}
}

68
client/types/enums.ts Normal file
View File

@ -0,0 +1,68 @@
export enum RoleCategory {
GLOBAL = 'global',
PROJECT = 'project',
ORGANIZATION = 'organization',
}
export enum ApiSessionType {
KEY = 'key',
USER = 'user',
PUBLIC = 'public',
DEV = 'dev',
}
export enum ProjectCategory {
ADMIN_TOOLS = 'admin_tools',
CHAT = 'chat',
DEV_TOOLS = 'dev_tools',
ECONOMY = 'economy',
GAMEPLAY = 'gameplay',
GAMES = 'games',
PROTECTION = 'protection',
ROLE_PLAYING = 'role_playing',
WORLD_MANAGEMENT = 'world_management',
MISC = 'misc',
UNDEFINED = 'undefined',
}
export enum Visibility {
PUBLIC = 'public',
NEW = 'new',
NEEDS_CHANGES = 'needsChanges',
NEEDS_APPROVAL = 'needsApproval',
SOFT_DELETE = 'softDelete',
}
// export class ProjectCategory {
// private static values: Map<string, ProjectCategory>;
// public static getValues(): IterableIterator<ProjectCategory> {
// return this.values.values();
// }
//
// public static getByName(name: string): ProjectCategory | undefined {
// return this.values.get(name);
// }
//
// public static ADMIN_TOOLS = ProjectCategory.create('admin_tools', 'Admin Tools', 'fa-server');
// public static CHAT = ProjectCategory.create('chat', 'Chat', 'fa-comment');
// public static DEV_TOOLS = ProjectCategory.create('dev_tools', 'Developer Tools', 'fa-wrench');
// public static ECONOMY = ProjectCategory.create('economy', 'Economy', 'fa-money-bill-alt');
// public static GAMEPLAY = ProjectCategory.create('gameplay', 'Gameplay', 'fa-puzzle-piece');
// public static GAMES = ProjectCategory.create('games', 'Games', 'fa-gamepad');
//
// name: string;
// title: string;
// icon: string;
//
// constructor(name: string, title: string, icon: string) {
// this.name = name;
// this.title = title;
// this.icon = icon;
// }
//
// static create(name: string, title: string, icon: string): ProjectCategory {
// const category = new ProjectCategory(name, title, icon);
// this.values.set(name, category);
// return category;
// }
// }

9753
client/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
package io.papermc.hangar.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import freemarker.core.HTMLOutputFormat;
import freemarker.template.TemplateException;
import io.papermc.hangar.controller.converters.ColorHexConverter;
@ -21,6 +20,7 @@ import org.springframework.http.converter.json.MappingJackson2HttpMessageConvert
import org.springframework.web.client.RestTemplate;
import org.springframework.web.filter.ShallowEtagHeaderFilter;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import org.springframework.web.servlet.resource.CssLinkResourceTransformer;
@ -54,6 +54,11 @@ public class WebConfig extends WebMvcConfigurationSupport {
return resolver;
}
@Override
protected void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**").allowedOrigins("http://localhost:3000");
}
@Bean
public FreeMarkerConfigurer freemarkerConfig() {
FreeMarkerConfigurer freeMarkerConfigurer = new FreeMarkerConfigurer();