generated from alphane/template
Compare commits
38 Commits
8fe16ef65c
...
blog
| Author | SHA1 | Date | |
|---|---|---|---|
| 133d607b2f | |||
| de28a67417 | |||
| 8d9fe8e75d | |||
| 9dfee98b84 | |||
| d33aef1680 | |||
| a0d5a7ec68 | |||
| 3468df3782 | |||
| 4a675f7da4 | |||
| 5b31972b0f | |||
| 02092692b6 | |||
| b7b473d079 | |||
| 1af13091e6 | |||
| 02ef729bf4 | |||
| 5ca7ca2028 | |||
| 03555d4744 | |||
| 0595285c25 | |||
| 39afe80a6c | |||
| 6161720955 | |||
| b7eaa2ad74 | |||
| 4347cb9c91 | |||
| 6f68049120 | |||
| f94aeca4cc | |||
| 9777a4229a | |||
| f7e474a011 | |||
| 6d211a1e83 | |||
| 7b7330d9b4 | |||
| 19352c7fa6 | |||
| ec6b9d2403 | |||
| e1664bf724 | |||
| d581c7b789 | |||
| 8457a97c5f | |||
| 4b91ba0936 | |||
| 8030e98d90 | |||
| 4c9e503e4f | |||
| cb60ec8480 | |||
| f83d4d579c | |||
| 033c962ebe | |||
| 69d4f7c3f0 |
@@ -0,0 +1,16 @@
|
|||||||
|
HOST_PORT=8080
|
||||||
|
API_PORT=8080
|
||||||
|
|
||||||
|
DB_HOST=db
|
||||||
|
DB_USERNAME=calebburkedev
|
||||||
|
DB_DATABASE=calebburkedev_production
|
||||||
|
DB_PASSWORD=DevPwd99!
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
DB_TRUST_SERVER_CERTIFICATE=true
|
||||||
|
|
||||||
|
VITE_APPLICATION_NAME="CALEB BURKE DEV"
|
||||||
|
VITE_API_BASE_URL="http://localhost:8080"
|
||||||
|
VITE_AUTH0_CLIENT_ID="TRlKzdNBynpo9tU1RSmnF0p8d3IEam4J"
|
||||||
|
VITE_AUTH0_AUDIENCE="alphane-api"
|
||||||
|
VITE_AUTH0_DOMAIN="https://dev-7mdjzcgwirhocfwm.ca.auth0.com"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY_URL }}
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ vars.REGISTRY_URL }}/${{ github.repository }}:latest
|
||||||
|
${{ vars.REGISTRY_URL }}/${{ github.repository }}:${{ github.sha }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||||
|
build-args: |
|
||||||
|
RELEASE_TAG=${{ github.ref_name }}
|
||||||
|
GIT_COMMIT_HASH=${{ github.sha }}
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
nodejs 20.10.0
|
|
||||||
ruby 3.2.2
|
|
||||||
@@ -1,5 +1,60 @@
|
|||||||
# calebburke.dev
|
# calebburke.dev
|
||||||
|
|
||||||
|
## Deploying
|
||||||
|
|
||||||
|
### Production Environment (remote)
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
### Test Production Build Locally
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- [Dockerfile](./Dockerfile)
|
||||||
|
- [docker-compose.yml](./docker-compose.yml)
|
||||||
|
- Non-commited `.env` file
|
||||||
|
|
||||||
|
1. Create a `.env` file in top level directory with the appropriate values.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOST_PORT=8080
|
||||||
|
API_PORT=8080
|
||||||
|
|
||||||
|
DB_HOST=db
|
||||||
|
DB_USERNAME=calebburkedev
|
||||||
|
DB_DATABASE=calebburkedev_production
|
||||||
|
DB_PASSWORD=DevPwd99!
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
DB_TRUST_SERVER_CERTIFICATE=true
|
||||||
|
|
||||||
|
VITE_APPLICATION_NAME="CALEB BURKE DEV"
|
||||||
|
VITE_API_BASE_URL="http://localhost:8080"
|
||||||
|
VITE_AUTH0_CLIENT_ID="TRlKzdNBynpo9tU1RSmnF0p8d3IEam4J"
|
||||||
|
VITE_AUTH0_AUDIENCE="alphane-api"
|
||||||
|
VITE_AUTH0_DOMAIN="https=//dev-7mdjzcgwirhocfwm.ca.auth0.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. (optional) If testing build arguments do
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build \
|
||||||
|
--build-arg RELEASE_TAG=$(date +%Y.%m.%d) \
|
||||||
|
--build-arg GIT_COMMIT_HASH=$(git rev-parse HEAD)
|
||||||
|
```
|
||||||
|
|
||||||
|
and then in the next step drop the `--build` flag.
|
||||||
|
|
||||||
|
3. Build and boot the production image via
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Go to <http://localhost:3000/> and log in.
|
||||||
|
|
||||||
|
5. Navigate around the app and do some stuff and see if it works.
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
|
|
||||||
- <https://github.com/cotes2020/jekyll-theme-chirpy>
|
- <https://github.com/cotes2020/jekyll-theme-chirpy>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const DB_PASSWORD = process.env.DB_PASSWORD || ""
|
|||||||
export const DB_DATABASE = process.env.DB_DATABASE || ""
|
export const DB_DATABASE = process.env.DB_DATABASE || ""
|
||||||
export const DB_PORT = parseInt(process.env.DB_PORT || "1433")
|
export const DB_PORT = parseInt(process.env.DB_PORT || "1433")
|
||||||
export const DB_TRUST_SERVER_CERTIFICATE = process.env.DB_TRUST_SERVER_CERTIFICATE === "true"
|
export const DB_TRUST_SERVER_CERTIFICATE = process.env.DB_TRUST_SERVER_CERTIFICATE === "true"
|
||||||
|
export const DB_SSL = process.env.DB_SSL === "true"
|
||||||
|
|
||||||
export const REDIS_CONNECTION_URL = process.env.REDIS_CONNECTION_URL || ""
|
export const REDIS_CONNECTION_URL = process.env.REDIS_CONNECTION_URL || ""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { isNil } from "lodash"
|
||||||
|
|
||||||
|
import logger from "@/utils/logger"
|
||||||
|
import { BlogPost } from "@/models"
|
||||||
|
import { BlogPostsPolicy } from "@/policies"
|
||||||
|
import { CreateService, DestroyService, UpdateService } from "@/services/blog-posts"
|
||||||
|
import { IndexSerializer, ShowSerializer } from "@/serializers/blog-posts"
|
||||||
|
import BaseController from "@/controllers/base-controller"
|
||||||
|
|
||||||
|
export class BlogPostsController extends BaseController<BlogPost> {
|
||||||
|
async index() {
|
||||||
|
try {
|
||||||
|
const where = this.buildWhere()
|
||||||
|
const scopes = this.buildFilterScopes()
|
||||||
|
const scopedBlogPosts = BlogPostsPolicy.applyScope(scopes, this.currentUser)
|
||||||
|
|
||||||
|
const totalCount = await scopedBlogPosts.count({ where })
|
||||||
|
const blogPosts = await scopedBlogPosts.findAll({
|
||||||
|
where,
|
||||||
|
limit: this.pagination.limit,
|
||||||
|
offset: this.pagination.offset,
|
||||||
|
order: this.buildOrder(),
|
||||||
|
})
|
||||||
|
const serializedBlogPosts = IndexSerializer.perform(blogPosts)
|
||||||
|
return this.response.json({ blogPosts: serializedBlogPosts, totalCount })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching blog posts" + error)
|
||||||
|
return this.response.status(400).json({ message: `Error fetching blog posts: ${error}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async show() {
|
||||||
|
try {
|
||||||
|
const blogPost = await this.loadBlogPost()
|
||||||
|
if (isNil(blogPost)) {
|
||||||
|
return this.response.status(404).json({ message: "Blog post not found" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = this.buildPolicy(blogPost)
|
||||||
|
if (!policy.show()) {
|
||||||
|
return this.response
|
||||||
|
.status(403)
|
||||||
|
.json({ message: "You are not authorized to view this blog post" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializedBlogPost = ShowSerializer.perform(blogPost)
|
||||||
|
return this.response.json({ blogPost: serializedBlogPost, policy })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching blog post" + error)
|
||||||
|
return this.response.status(400).json({ message: `Error fetching blog post: ${error}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create() {
|
||||||
|
try {
|
||||||
|
const policy = this.buildPolicy()
|
||||||
|
if (!policy.create()) {
|
||||||
|
return this.response
|
||||||
|
.status(403)
|
||||||
|
.json({ message: "You are not authorized to create blog posts" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const permittedAttributes = policy.permitAttributesForCreate(this.request.body)
|
||||||
|
const blogPost = await CreateService.perform({
|
||||||
|
...permittedAttributes,
|
||||||
|
creatorId: this.currentUser.id,
|
||||||
|
})
|
||||||
|
const serializedBlogPost = ShowSerializer.perform(blogPost)
|
||||||
|
return this.response.status(201).json({ blogPost: serializedBlogPost })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error creating blog post" + error)
|
||||||
|
return this.response.status(422).json({ message: `Error creating blog post: ${error}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update() {
|
||||||
|
try {
|
||||||
|
const blogPost = await this.loadBlogPost()
|
||||||
|
if (isNil(blogPost)) {
|
||||||
|
return this.response.status(404).json({ message: "Blog post not found" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = this.buildPolicy(blogPost)
|
||||||
|
if (!policy.update()) {
|
||||||
|
return this.response
|
||||||
|
.status(403)
|
||||||
|
.json({ message: "You are not authorized to update this blog post" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const permittedAttributes = policy.permitAttributes(this.request.body)
|
||||||
|
const updatedBlogPost = await UpdateService.perform(blogPost, permittedAttributes)
|
||||||
|
const serializedBlogPost = ShowSerializer.perform(updatedBlogPost)
|
||||||
|
return this.response.json({ blogPost: serializedBlogPost })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating blog post" + error)
|
||||||
|
return this.response.status(422).json({ message: `Error updating blog post: ${error}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy() {
|
||||||
|
try {
|
||||||
|
const blogPost = await this.loadBlogPost()
|
||||||
|
if (isNil(blogPost)) {
|
||||||
|
return this.response.status(404).json({ message: "Blog post not found" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = this.buildPolicy(blogPost)
|
||||||
|
if (!policy.destroy()) {
|
||||||
|
return this.response
|
||||||
|
.status(403)
|
||||||
|
.json({ message: "You are not authorized to delete this blog post" })
|
||||||
|
}
|
||||||
|
|
||||||
|
await DestroyService.perform(blogPost)
|
||||||
|
return this.response.status(204).send()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error deleting blog post" + error)
|
||||||
|
return this.response.status(422).json({ message: `Error deleting blog post: ${error}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadBlogPost() {
|
||||||
|
return BlogPost.findBySlugOrPk(this.params.blogPostIdOrSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPolicy(blogPost: BlogPost = BlogPost.build()) {
|
||||||
|
return new BlogPostsPolicy(this.currentUser, blogPost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlogPostsController
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
// Controllers
|
// Controllers
|
||||||
|
export { BlogPostsController } from "./blog-posts-controller"
|
||||||
export { CurrentUserController } from "./current-user-controller"
|
export { CurrentUserController } from "./current-user-controller"
|
||||||
export { FlashcardDecksController } from "./flashcard-decks-controller"
|
export { FlashcardDecksController } from "./flashcard-decks-controller"
|
||||||
export { FlashcardsController } from "./flashcards-controller"
|
export { FlashcardsController } from "./flashcards-controller"
|
||||||
export { UsersController } from "./users-controller"
|
export { UsersController } from "./users-controller"
|
||||||
|
|
||||||
|
export * as Public from "./public"
|
||||||
|
export * as Users from "./users"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Model } from "@sequelize/core"
|
||||||
|
|
||||||
|
import BaseController from "@/controllers/base-controller"
|
||||||
|
|
||||||
|
// Base controller for public, unauthenticated routes: these are mounted
|
||||||
|
// before jwtMiddleware/authorizationMiddleware in router.ts, so no
|
||||||
|
// currentUser is ever available.
|
||||||
|
export class PublicBaseController<TModel extends Model = never> extends BaseController<TModel> {
|
||||||
|
get currentUser(): never {
|
||||||
|
throw new Error("currentUser is not available on public controllers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PublicBaseController
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { isNil } from "lodash"
|
||||||
|
|
||||||
|
import logger from "@/utils/logger"
|
||||||
|
import { BlogPost } from "@/models"
|
||||||
|
import { IndexSerializer, ShowSerializer } from "@/serializers/blog-posts"
|
||||||
|
import PublicBaseController from "@/controllers/public/base-controller"
|
||||||
|
|
||||||
|
export class BlogPostsController extends PublicBaseController<BlogPost> {
|
||||||
|
async index() {
|
||||||
|
try {
|
||||||
|
const where = this.buildWhere()
|
||||||
|
const totalCount = await BlogPost.count({ where })
|
||||||
|
const blogPosts = await BlogPost.findAll({
|
||||||
|
where,
|
||||||
|
limit: this.pagination.limit,
|
||||||
|
offset: this.pagination.offset,
|
||||||
|
order: this.buildOrder(),
|
||||||
|
})
|
||||||
|
const serializedBlogPosts = IndexSerializer.perform(blogPosts)
|
||||||
|
return this.response.json({ blogPosts: serializedBlogPosts, totalCount })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching blog posts" + error)
|
||||||
|
return this.response.status(400).json({ message: `Error fetching blog posts: ${error}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async show() {
|
||||||
|
try {
|
||||||
|
const blogPost = await this.loadBlogPost()
|
||||||
|
if (isNil(blogPost)) {
|
||||||
|
return this.response.status(404).json({ message: "Blog post not found" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializedBlogPost = ShowSerializer.perform(blogPost)
|
||||||
|
return this.response.json({ blogPost: serializedBlogPost })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching blog post" + error)
|
||||||
|
return this.response.status(400).json({ message: `Error fetching blog post: ${error}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadBlogPost() {
|
||||||
|
return BlogPost.findBySlugOrPk(this.params.blogPostIdOrSlug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlogPostsController
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Public (unauthenticated) controllers
|
||||||
|
export { BlogPostsController } from "./blog-posts-controller"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { PreferencesController } from "./preferences-controller"
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { isNil } from "lodash"
|
||||||
|
|
||||||
|
import logger from "@/utils/logger"
|
||||||
|
|
||||||
|
import { User } from "@/models"
|
||||||
|
import { UpsertService } from "@/services/users/preferences"
|
||||||
|
import { UsersPolicy } from "@/policies/users-policy"
|
||||||
|
import BaseController from "@/controllers/base-controller"
|
||||||
|
|
||||||
|
export class PreferencesController extends BaseController {
|
||||||
|
async update() {
|
||||||
|
try {
|
||||||
|
const user = await this.loadUser()
|
||||||
|
if (isNil(user)) {
|
||||||
|
return this.response.status(404).json({
|
||||||
|
message: "User not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = this.buildPolicy(user)
|
||||||
|
if (!policy.update()) {
|
||||||
|
return this.response.status(403).json({
|
||||||
|
message: "You are not authorized to update this user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = this.request.params.key as string
|
||||||
|
const { value } = this.request.body
|
||||||
|
await UpsertService.perform(user, key, value)
|
||||||
|
|
||||||
|
return this.response.json({ message: "Successfully upserted user preference" })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error)
|
||||||
|
return this.response.status(422).json({
|
||||||
|
message: `Error updating user: ${error}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadUser(): Promise<User | null> {
|
||||||
|
return User.findByPk(this.params.UserId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPolicy(User: User) {
|
||||||
|
return new UsersPolicy(this.currentUser, User)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PreferencesController
|
||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
DB_HOST,
|
DB_HOST,
|
||||||
DB_PASSWORD,
|
DB_PASSWORD,
|
||||||
DB_PORT,
|
DB_PORT,
|
||||||
|
DB_SSL,
|
||||||
DB_USERNAME,
|
DB_USERNAME,
|
||||||
NODE_ENV,
|
|
||||||
SEQUELIZE_LOGGING,
|
SEQUELIZE_LOGGING,
|
||||||
} from "@/config"
|
} from "@/config"
|
||||||
import compactSql from "@/utils/compact-sql"
|
import compactSql from "@/utils/compact-sql"
|
||||||
@@ -31,7 +31,7 @@ export const SEQUELIZE_CONFIG: Options<PostgresDialect> = {
|
|||||||
password: DB_PASSWORD,
|
password: DB_PASSWORD,
|
||||||
host: DB_HOST,
|
host: DB_HOST,
|
||||||
port: DB_PORT,
|
port: DB_PORT,
|
||||||
ssl: NODE_ENV !== "production" ? false : { rejectUnauthorized: false },
|
ssl: DB_SSL ? { rejectUnauthorized: false } : false,
|
||||||
schema: "public", // default - explicit for clarity
|
schema: "public", // default - explicit for clarity
|
||||||
logging: SEQUELIZE_LOGGING ? sqlLogger : false,
|
logging: SEQUELIZE_LOGGING ? sqlLogger : false,
|
||||||
pool: {
|
pool: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import path from "path"
|
|||||||
import knex, { Knex } from "knex"
|
import knex, { Knex } from "knex"
|
||||||
import { isEmpty, isNil, merge } from "lodash"
|
import { isEmpty, isNil, merge } from "lodash"
|
||||||
|
|
||||||
import { DB_DATABASE, DB_HOST, DB_PASSWORD, DB_PORT, DB_USERNAME, NODE_ENV } from "@/config"
|
import { DB_DATABASE, DB_HOST, DB_PASSWORD, DB_PORT, DB_SSL, DB_USERNAME, NODE_ENV } from "@/config"
|
||||||
|
|
||||||
if (isEmpty(DB_DATABASE)) throw new Error("database name is unset.")
|
if (isEmpty(DB_DATABASE)) throw new Error("database name is unset.")
|
||||||
if (isEmpty(DB_USERNAME)) throw new Error("database username is unset.")
|
if (isEmpty(DB_USERNAME)) throw new Error("database username is unset.")
|
||||||
@@ -21,17 +21,7 @@ export function buildKnexConfig(options?: Knex.Config): Knex.Config {
|
|||||||
password: DB_PASSWORD,
|
password: DB_PASSWORD,
|
||||||
database: DB_DATABASE,
|
database: DB_DATABASE,
|
||||||
port: DB_PORT,
|
port: DB_PORT,
|
||||||
ssl:
|
ssl: DB_SSL ? { rejectUnauthorized: false } : false,
|
||||||
NODE_ENV !== "production"
|
|
||||||
? false
|
|
||||||
: {
|
|
||||||
require: true, // Enforce SSL
|
|
||||||
rejectUnauthorized: false, // Disable certificate verification (common for Azure)
|
|
||||||
},
|
|
||||||
/* options: {
|
|
||||||
encrypt: true,
|
|
||||||
trustServerCertificate: DB_TRUST_SERVER_CERTIFICATE,
|
|
||||||
}, */
|
|
||||||
},
|
},
|
||||||
migrations: {
|
migrations: {
|
||||||
directory: path.resolve(__dirname, "./migrations"),
|
directory: path.resolve(__dirname, "./migrations"),
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Knex } from "knex"
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.createTable("blog_posts", function (table) {
|
||||||
|
table.increments("id").notNullable().primary()
|
||||||
|
table.integer("creator_id").notNullable()
|
||||||
|
table.string("title", 255).notNullable()
|
||||||
|
table.string("slug", 255).notNullable()
|
||||||
|
table.text("content").notNullable()
|
||||||
|
table.string("tags", 1024).notNullable().defaultTo("")
|
||||||
|
table.specificType("published_at", "TIMESTAMP WITH TIME ZONE")
|
||||||
|
|
||||||
|
table
|
||||||
|
.specificType("created_at", "TIMESTAMP WITH TIME ZONE")
|
||||||
|
.notNullable()
|
||||||
|
.defaultTo(knex.raw("CURRENT_TIMESTAMP(0)"))
|
||||||
|
table
|
||||||
|
.specificType("updated_at", "TIMESTAMP WITH TIME ZONE")
|
||||||
|
.notNullable()
|
||||||
|
.defaultTo(knex.raw("CURRENT_TIMESTAMP(0)"))
|
||||||
|
table.specificType("deleted_at", "TIMESTAMP WITH TIME ZONE")
|
||||||
|
|
||||||
|
table.foreign("creator_id").references("users.id")
|
||||||
|
table.unique(["slug"], {
|
||||||
|
indexName: "blog_posts_slug_unique",
|
||||||
|
predicate: knex.whereNull("deleted_at"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.dropTable("blog_posts")
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Knex } from "knex"
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.createTable("user_preferences", (table) => {
|
||||||
|
table.increments("id").primary()
|
||||||
|
table.integer("user_id").notNullable()
|
||||||
|
table.string("key", 255).notNullable()
|
||||||
|
table.jsonb("value").nullable()
|
||||||
|
table
|
||||||
|
.specificType("created_at", "TIMESTAMP WITH TIME ZONE")
|
||||||
|
.notNullable()
|
||||||
|
.defaultTo(knex.raw("CURRENT_TIMESTAMP(0)"))
|
||||||
|
table
|
||||||
|
.specificType("updated_at", "TIMESTAMP WITH TIME ZONE")
|
||||||
|
.notNullable()
|
||||||
|
.defaultTo(knex.raw("CURRENT_TIMESTAMP(0)"))
|
||||||
|
table.specificType("deleted_at", "TIMESTAMP WITH TIME ZONE")
|
||||||
|
|
||||||
|
table.foreign("user_id").references("users.id")
|
||||||
|
|
||||||
|
table.unique(["user_id", "key"], {
|
||||||
|
indexName: "unique_user_preference_when_deleted_at_is_null",
|
||||||
|
predicate: knex.whereNull("deleted_at"),
|
||||||
|
})
|
||||||
|
|
||||||
|
table.index(["user_id"], "preferences_user_id")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.dropTable("user_preferences")
|
||||||
|
}
|
||||||
@@ -45,25 +45,25 @@ export abstract class BaseModel<
|
|||||||
// identifier: unknown,
|
// identifier: unknown,
|
||||||
// options?: FindByPkOptions<M>,
|
// options?: FindByPkOptions<M>,
|
||||||
// ): Promise<M | null>;
|
// ): Promise<M | null>;
|
||||||
public static async findByIdentifierOrPk<M extends BaseModel>(
|
public static async findBySlugOrPk<M extends BaseModel>(
|
||||||
this: ModelStatic<M>,
|
this: ModelStatic<M>,
|
||||||
identifierOrPk: string | number,
|
slugOrPk: unknown,
|
||||||
options?: Omit<FindOptions<Attributes<M>>, "where">
|
options?: Omit<FindOptions<Attributes<M>>, "where">
|
||||||
): Promise<M | null> {
|
): Promise<M | null> {
|
||||||
if (typeof identifierOrPk === "number" || !isNaN(Number(identifierOrPk))) {
|
if (typeof slugOrPk === "number" || !isNaN(Number(slugOrPk))) {
|
||||||
const primaryKey = identifierOrPk
|
const primaryKey = slugOrPk
|
||||||
return this.findByPk(primaryKey, options)
|
return this.findByPk(primaryKey, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifier = identifierOrPk
|
const slug = slugOrPk
|
||||||
if (!("identifier" in this.getAttributes())) {
|
if (!("slug" in this.getAttributes())) {
|
||||||
throw new Error(`${this.name} does not have a 'identifier' attribute.`)
|
throw new Error(`${this.name} does not have a 'slug' attribute.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.findOne({
|
return this.findOne({
|
||||||
...options,
|
...options,
|
||||||
// @ts-expect-error - We know that the model has a slug attribute, and are ignoring the TS error
|
// @ts-expect-error - We know that the model has a slug attribute, and are ignoring the TS error
|
||||||
where: { identifier },
|
where: { slug },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { type CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, sql } from "@sequelize/core"
|
||||||
|
import { Attribute, AutoIncrement, Default, Index, NotNull, PrimaryKey } from "@sequelize/core/decorators-legacy"
|
||||||
|
import { isNil } from "lodash"
|
||||||
|
|
||||||
|
import BaseModel from "@/models/base-model"
|
||||||
|
|
||||||
|
export class BlogPost extends BaseModel<InferAttributes<BlogPost>, InferCreationAttributes<BlogPost>> {
|
||||||
|
@Attribute(DataTypes.INTEGER)
|
||||||
|
@PrimaryKey
|
||||||
|
@AutoIncrement
|
||||||
|
declare id: CreationOptional<number>
|
||||||
|
|
||||||
|
@Attribute(DataTypes.INTEGER)
|
||||||
|
@NotNull
|
||||||
|
declare creatorId: number
|
||||||
|
|
||||||
|
@Attribute(DataTypes.STRING(255))
|
||||||
|
@NotNull
|
||||||
|
declare title: string
|
||||||
|
|
||||||
|
@Attribute(DataTypes.STRING(255))
|
||||||
|
@NotNull
|
||||||
|
@Index({ unique: true })
|
||||||
|
declare slug: string
|
||||||
|
|
||||||
|
@Attribute(DataTypes.TEXT)
|
||||||
|
@NotNull
|
||||||
|
declare content: string
|
||||||
|
|
||||||
|
@Attribute({
|
||||||
|
type: DataTypes.STRING(1024),
|
||||||
|
get() {
|
||||||
|
const tags = this.getDataValue("tags")
|
||||||
|
if (isNil(tags) || tags === "") {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return tags.split(",")
|
||||||
|
},
|
||||||
|
set(value: string[]) {
|
||||||
|
this.setDataValue("tags", value.join(","))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@NotNull
|
||||||
|
@Default("")
|
||||||
|
declare tags: CreationOptional<string[]>
|
||||||
|
|
||||||
|
@Attribute(DataTypes.DATE(0))
|
||||||
|
declare publishedAt: Date | null
|
||||||
|
|
||||||
|
@Attribute(DataTypes.DATE(0))
|
||||||
|
@NotNull
|
||||||
|
@Default(sql.literal("CURRENT_TIMESTAMP"))
|
||||||
|
declare createdAt: CreationOptional<Date>
|
||||||
|
|
||||||
|
@Attribute(DataTypes.DATE(0))
|
||||||
|
@NotNull
|
||||||
|
@Default(sql.literal("CURRENT_TIMESTAMP"))
|
||||||
|
declare updatedAt: CreationOptional<Date>
|
||||||
|
|
||||||
|
@Attribute(DataTypes.DATE(0))
|
||||||
|
declare deletedAt: Date | null
|
||||||
|
|
||||||
|
// Scopes
|
||||||
|
static establishScopes(): void {
|
||||||
|
this.addSearchScope(["title", "content"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlogPost
|
||||||
+6
-11
@@ -1,27 +1,22 @@
|
|||||||
import db from "@/db/db-client"
|
import db from "@/db/db-client"
|
||||||
|
|
||||||
// Models
|
// Models
|
||||||
|
import BlogPost from "@/models/blog-post"
|
||||||
import Flashcard from "@/models/flashcard"
|
import Flashcard from "@/models/flashcard"
|
||||||
import FlashcardDeck from "@/models/flashcard-deck"
|
import FlashcardDeck from "@/models/flashcard-deck"
|
||||||
import User, { UserRoles } from "@/models/user"
|
import User, { UserRoles } from "@/models/user"
|
||||||
|
import UserPreference from "@/models/user-preference"
|
||||||
|
|
||||||
db.addModels([
|
db.addModels([BlogPost, Flashcard, FlashcardDeck, User, UserPreference])
|
||||||
Flashcard,
|
|
||||||
FlashcardDeck,
|
|
||||||
User,
|
|
||||||
])
|
|
||||||
|
|
||||||
// Lazy load scopes
|
// Lazy load scopes
|
||||||
|
BlogPost.establishScopes()
|
||||||
Flashcard.establishScopes()
|
Flashcard.establishScopes()
|
||||||
FlashcardDeck.establishScopes()
|
FlashcardDeck.establishScopes()
|
||||||
User.establishScopes()
|
User.establishScopes()
|
||||||
|
UserPreference.establishScopes()
|
||||||
|
|
||||||
export {
|
export { BlogPost, Flashcard, FlashcardDeck, User, UserRoles, UserPreference }
|
||||||
Flashcard,
|
|
||||||
FlashcardDeck,
|
|
||||||
User,
|
|
||||||
UserRoles,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special db instance will all models loaded
|
// Special db instance will all models loaded
|
||||||
export default db
|
export default db
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { PreferenceKeys, PreferenceValueMap } from "@/models/user-preference"
|
||||||
|
import { isFiniteNumber } from "@/utils/validators"
|
||||||
|
|
||||||
|
export const preferenceValueValidators: {
|
||||||
|
[K in PreferenceKeys]: (value: unknown) => value is PreferenceValueMap[K]
|
||||||
|
} = {
|
||||||
|
[PreferenceKeys.FLASHCARDS_DAY_STREAK]: isFiniteNumber,
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import {
|
||||||
|
type CreationOptional,
|
||||||
|
type ForeignKey,
|
||||||
|
DataTypes,
|
||||||
|
InferAttributes,
|
||||||
|
InferCreationAttributes,
|
||||||
|
type NonAttribute,
|
||||||
|
sql,
|
||||||
|
} from "@sequelize/core"
|
||||||
|
import {
|
||||||
|
Attribute,
|
||||||
|
AutoIncrement,
|
||||||
|
BelongsTo,
|
||||||
|
Default,
|
||||||
|
NotNull,
|
||||||
|
PrimaryKey,
|
||||||
|
Table,
|
||||||
|
ValidateAttribute,
|
||||||
|
} from "@sequelize/core/decorators-legacy"
|
||||||
|
|
||||||
|
import BaseModel from "@/models/base-model"
|
||||||
|
import { User } from "@/models/user"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep in sync with web/src/api/users/preferences-api.ts and
|
||||||
|
* api/src/models/user-preference-value-validators.ts
|
||||||
|
*/
|
||||||
|
export enum PreferenceKeys {
|
||||||
|
FLASHCARDS_DAY_STREAK = "flashcards.day_streak",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PreferenceValueMap = {
|
||||||
|
[PreferenceKeys.FLASHCARDS_DAY_STREAK]: number
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table({ tableName: "user_preferences", timestamps: true, paranoid: true })
|
||||||
|
export class UserPreference extends BaseModel<
|
||||||
|
InferAttributes<UserPreference>,
|
||||||
|
InferCreationAttributes<UserPreference>
|
||||||
|
> {
|
||||||
|
static readonly Keys = PreferenceKeys
|
||||||
|
|
||||||
|
@Attribute(DataTypes.INTEGER)
|
||||||
|
@PrimaryKey
|
||||||
|
@AutoIncrement
|
||||||
|
declare id: CreationOptional<number>
|
||||||
|
|
||||||
|
@Attribute(DataTypes.INTEGER)
|
||||||
|
@NotNull
|
||||||
|
declare userId: ForeignKey<User["id"]>
|
||||||
|
|
||||||
|
@Attribute(DataTypes.STRING(255))
|
||||||
|
@NotNull
|
||||||
|
@ValidateAttribute({
|
||||||
|
isIn: {
|
||||||
|
args: [Object.values(PreferenceKeys)],
|
||||||
|
msg: `Key must be one of ${Object.values(PreferenceKeys).join(", ")}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
declare key: PreferenceKeys
|
||||||
|
|
||||||
|
@Attribute(DataTypes.JSONB)
|
||||||
|
declare value: unknown | null
|
||||||
|
|
||||||
|
@Attribute(DataTypes.DATE(0))
|
||||||
|
@NotNull
|
||||||
|
@Default(sql.literal("CURRENT_TIMESTAMP"))
|
||||||
|
declare createdAt: CreationOptional<Date>
|
||||||
|
|
||||||
|
@Attribute(DataTypes.DATE(0))
|
||||||
|
@NotNull
|
||||||
|
@Default(sql.literal("CURRENT_TIMESTAMP"))
|
||||||
|
declare updatedAt: CreationOptional<Date>
|
||||||
|
|
||||||
|
@Attribute(DataTypes.DATE(0))
|
||||||
|
declare deletedAt: Date | null
|
||||||
|
|
||||||
|
// Associations
|
||||||
|
@BelongsTo(() => User, {
|
||||||
|
foreignKey: { name: "userId", allowNull: false },
|
||||||
|
inverse: {
|
||||||
|
as: "userPreferences",
|
||||||
|
type: "hasMany",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
declare user?: NonAttribute<User>
|
||||||
|
|
||||||
|
// Scopes
|
||||||
|
static establishScopes(): void {}
|
||||||
|
|
||||||
|
static async setValue<T extends PreferenceKeys>(
|
||||||
|
userId: number,
|
||||||
|
key: T,
|
||||||
|
value: PreferenceValueMap[T]
|
||||||
|
) {
|
||||||
|
const existing = await UserPreference.findOne({
|
||||||
|
where: { userId, key },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return existing.update({ value })
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserPreference.create({ userId, key, value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserPreference
|
||||||
+13
-1
@@ -10,6 +10,7 @@ import {
|
|||||||
Attribute,
|
Attribute,
|
||||||
AutoIncrement,
|
AutoIncrement,
|
||||||
Default,
|
Default,
|
||||||
|
HasMany,
|
||||||
Index,
|
Index,
|
||||||
NotNull,
|
NotNull,
|
||||||
PrimaryKey,
|
PrimaryKey,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
import { isArray, isNil } from "lodash"
|
import { isArray, isNil } from "lodash"
|
||||||
|
|
||||||
import BaseModel from "@/models/base-model"
|
import BaseModel from "@/models/base-model"
|
||||||
|
import UserPreference from "@/models/user-preference"
|
||||||
|
|
||||||
/** Keep in sync with web/src/api/users-api.ts */
|
/** Keep in sync with web/src/api/users-api.ts */
|
||||||
export enum UserRoles {
|
export enum UserRoles {
|
||||||
@@ -98,12 +100,22 @@ export class User extends BaseModel<InferAttributes<User>, InferCreationAttribut
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Associations
|
// Associations
|
||||||
|
@HasMany(() => UserPreference, {
|
||||||
|
foreignKey: {
|
||||||
|
name: "userId",
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
inverse: "user",
|
||||||
|
})
|
||||||
|
declare userPreferences?: NonAttribute<UserPreference[]>
|
||||||
|
|
||||||
// Scopes
|
// Scopes
|
||||||
static establishScopes(): void {
|
static establishScopes(): void {
|
||||||
this.addSearchScope(["firstName", "lastName", "displayName", "email"])
|
this.addSearchScope(["firstName", "lastName", "displayName", "email"])
|
||||||
|
|
||||||
this.addScope("asCurrentUser", {})
|
this.addScope("asCurrentUser", {
|
||||||
|
include: ["userPreferences"],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Attributes, FindOptions } from "@sequelize/core"
|
||||||
|
|
||||||
|
import { BlogPost, User } from "@/models"
|
||||||
|
import { ALL_RECORDS_SCOPE, PolicyFactory } from "@/policies/base-policy"
|
||||||
|
import { Path } from "@/utils/deep-pick"
|
||||||
|
|
||||||
|
export class BlogPostsPolicy extends PolicyFactory(BlogPost) {
|
||||||
|
show(): boolean {
|
||||||
|
if (this.user.isSystemAdmin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): boolean {
|
||||||
|
if (this.user.isSystemAdmin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
update(): boolean {
|
||||||
|
if (this.user.isSystemAdmin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): boolean {
|
||||||
|
if (this.user.isSystemAdmin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
permittedAttributes(): Path[] {
|
||||||
|
return ["title", "slug", "content", "tags", "publishedAt"] as (keyof Attributes<BlogPost>)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
permittedAttributesForCreate(): Path[] {
|
||||||
|
return [...this.permittedAttributes()]
|
||||||
|
}
|
||||||
|
|
||||||
|
permittedAttributesForUpdate(): Path[] {
|
||||||
|
return [...this.permittedAttributes()]
|
||||||
|
}
|
||||||
|
|
||||||
|
static policyScope(_user: User): FindOptions<Attributes<BlogPost>> {
|
||||||
|
return ALL_RECORDS_SCOPE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlogPostsPolicy
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// Policy Bundles
|
// Policy Bundles
|
||||||
export { type BaseScopeOptions } from "./base-policy"
|
export { type BaseScopeOptions } from "./base-policy"
|
||||||
|
export { BlogPostsPolicy } from "./blog-posts-policy"
|
||||||
export { FlashcardDecksPolicy } from "./flashcard-decks-policy"
|
export { FlashcardDecksPolicy } from "./flashcard-decks-policy"
|
||||||
export { FlashcardsPolicy } from "./flashcards-policy"
|
export { FlashcardsPolicy } from "./flashcards-policy"
|
||||||
export { UsersPolicy } from "./users-policy"
|
export { UsersPolicy } from "./users-policy"
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ export class UsersPolicy extends PolicyFactory(User) {
|
|||||||
"displayName",
|
"displayName",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (this.user.isSystemAdmin) {
|
||||||
|
attributes.push("roles")
|
||||||
|
}
|
||||||
|
|
||||||
return attributes
|
return attributes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-5
@@ -16,7 +16,15 @@ import { logger } from "@/utils/logger"
|
|||||||
|
|
||||||
import { jwtMiddleware, authorizationMiddleware } from "@/middlewares"
|
import { jwtMiddleware, authorizationMiddleware } from "@/middlewares"
|
||||||
|
|
||||||
import { CurrentUserController, FlashcardDecksController, FlashcardsController, UsersController } from "@/controllers"
|
import {
|
||||||
|
BlogPostsController,
|
||||||
|
CurrentUserController,
|
||||||
|
FlashcardDecksController,
|
||||||
|
FlashcardsController,
|
||||||
|
Public,
|
||||||
|
Users,
|
||||||
|
UsersController,
|
||||||
|
} from "@/controllers"
|
||||||
|
|
||||||
export const router = Router()
|
export const router = Router()
|
||||||
|
|
||||||
@@ -29,6 +37,8 @@ router.route("/_status").get((_req: Request, res: Response) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// external (public) routes - no authentication required
|
// external (public) routes - no authentication required
|
||||||
|
router.route("/api/public/blog-posts").get(Public.BlogPostsController.index)
|
||||||
|
router.route("/api/public/blog-posts/:blogPostIdOrSlug").get(Public.BlogPostsController.show)
|
||||||
|
|
||||||
// api routes
|
// api routes
|
||||||
router.use("/api", jwtMiddleware, authorizationMiddleware)
|
router.use("/api", jwtMiddleware, authorizationMiddleware)
|
||||||
@@ -41,6 +51,7 @@ router
|
|||||||
.get(UsersController.show)
|
.get(UsersController.show)
|
||||||
.patch(UsersController.update)
|
.patch(UsersController.update)
|
||||||
.delete(UsersController.destroy)
|
.delete(UsersController.destroy)
|
||||||
|
router.route("/api/users/:userId/preferences/:key").patch(Users.PreferencesController.update)
|
||||||
|
|
||||||
router
|
router
|
||||||
.route("/api/flashcard-decks")
|
.route("/api/flashcard-decks")
|
||||||
@@ -52,16 +63,20 @@ router
|
|||||||
.patch(FlashcardDecksController.update)
|
.patch(FlashcardDecksController.update)
|
||||||
.delete(FlashcardDecksController.destroy)
|
.delete(FlashcardDecksController.destroy)
|
||||||
|
|
||||||
router
|
router.route("/api/flashcards").get(FlashcardsController.index).post(FlashcardsController.create)
|
||||||
.route("/api/flashcards")
|
|
||||||
.get(FlashcardsController.index)
|
|
||||||
.post(FlashcardsController.create)
|
|
||||||
router
|
router
|
||||||
.route("/api/flashcards/:flashcardId")
|
.route("/api/flashcards/:flashcardId")
|
||||||
.get(FlashcardsController.show)
|
.get(FlashcardsController.show)
|
||||||
.patch(FlashcardsController.update)
|
.patch(FlashcardsController.update)
|
||||||
.delete(FlashcardsController.destroy)
|
.delete(FlashcardsController.destroy)
|
||||||
|
|
||||||
|
router.route("/api/blog-posts").get(BlogPostsController.index).post(BlogPostsController.create)
|
||||||
|
router
|
||||||
|
.route("/api/blog-posts/:blogPostIdOrSlug")
|
||||||
|
.get(BlogPostsController.show)
|
||||||
|
.patch(BlogPostsController.update)
|
||||||
|
.delete(BlogPostsController.destroy)
|
||||||
|
|
||||||
// if no other routes match, return a 404
|
// if no other routes match, return a 404
|
||||||
router.use("/api", (req: Request, res: Response) => {
|
router.use("/api", (req: Request, res: Response) => {
|
||||||
return res.status(404).json({ message: "Not Found", url: req.path })
|
return res.status(404).json({ message: "Not Found", url: req.path })
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { pick } from "lodash"
|
||||||
|
|
||||||
|
import { BlogPost } from "@/models"
|
||||||
|
import BaseSerializer from "@/serializers/base-serializer"
|
||||||
|
|
||||||
|
export type BlogPostIndexView = Pick<
|
||||||
|
BlogPost,
|
||||||
|
"id" | "creatorId" | "title" | "slug" | "tags" | "publishedAt" | "createdAt" | "updatedAt"
|
||||||
|
>
|
||||||
|
|
||||||
|
export class IndexSerializer extends BaseSerializer<BlogPost> {
|
||||||
|
perform(): BlogPostIndexView {
|
||||||
|
return pick(this.record, [
|
||||||
|
"id",
|
||||||
|
"creatorId",
|
||||||
|
"title",
|
||||||
|
"slug",
|
||||||
|
"tags",
|
||||||
|
"publishedAt",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IndexSerializer
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { IndexSerializer } from "./index-serializer"
|
||||||
|
export { ShowSerializer } from "./show-serializer"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { pick } from "lodash"
|
||||||
|
|
||||||
|
import { BlogPost } from "@/models"
|
||||||
|
import BaseSerializer from "@/serializers/base-serializer"
|
||||||
|
|
||||||
|
export type BlogPostShowView = Pick<
|
||||||
|
BlogPost,
|
||||||
|
| "id"
|
||||||
|
| "creatorId"
|
||||||
|
| "title"
|
||||||
|
| "slug"
|
||||||
|
| "content"
|
||||||
|
| "tags"
|
||||||
|
| "publishedAt"
|
||||||
|
| "createdAt"
|
||||||
|
| "updatedAt"
|
||||||
|
>
|
||||||
|
|
||||||
|
export class ShowSerializer extends BaseSerializer<BlogPost> {
|
||||||
|
perform(): BlogPostShowView {
|
||||||
|
return pick(this.record, [
|
||||||
|
"id",
|
||||||
|
"creatorId",
|
||||||
|
"title",
|
||||||
|
"slug",
|
||||||
|
"content",
|
||||||
|
"tags",
|
||||||
|
"publishedAt",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShowSerializer
|
||||||
@@ -1,15 +1,27 @@
|
|||||||
import { pick } from "lodash"
|
import { isUndefined, pick } from "lodash"
|
||||||
|
|
||||||
import { User } from "@/models"
|
import { User, UserPreference } from "@/models"
|
||||||
import BaseSerializer from "@/serializers/base-serializer"
|
import BaseSerializer from "@/serializers/base-serializer"
|
||||||
|
import {
|
||||||
|
ShowSerializer as UserPreferenceShowSerializer,
|
||||||
|
type UserPreferenceShowView,
|
||||||
|
} from "@/serializers/user-preferences/show-serializer"
|
||||||
|
|
||||||
export type UserShowView = Pick<
|
export type UserShowView = Pick<
|
||||||
User,
|
User,
|
||||||
"id" | "email" | "firstName" | "lastName" | "displayName" | "roles" | "createdAt" | "updatedAt"
|
"id" | "email" | "firstName" | "lastName" | "displayName" | "roles" | "createdAt" | "updatedAt"
|
||||||
>
|
> & {
|
||||||
|
preferences?: UserPreferenceShowView[]
|
||||||
|
}
|
||||||
|
|
||||||
export class ShowSerializer extends BaseSerializer<User> {
|
export class ShowSerializer extends BaseSerializer<User> {
|
||||||
perform(): UserShowView {
|
perform(): UserShowView {
|
||||||
|
const { userPreferences } = this.record
|
||||||
|
|
||||||
|
const serializedUserPreferences = isUndefined(userPreferences)
|
||||||
|
? undefined
|
||||||
|
: this.serializeUserPreferences(userPreferences)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...pick(this.record, [
|
...pick(this.record, [
|
||||||
"id",
|
"id",
|
||||||
@@ -21,8 +33,15 @@ export class ShowSerializer extends BaseSerializer<User> {
|
|||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
]),
|
]),
|
||||||
|
preferences: serializedUserPreferences,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private serializeUserPreferences(userPreferences: UserPreference[]) {
|
||||||
|
return userPreferences.map((userPreference) =>
|
||||||
|
UserPreferenceShowSerializer.perform(userPreference)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ShowSerializer
|
export default ShowSerializer
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Bundled exports
|
// Bundled exports
|
||||||
|
export * as BlogPosts from "./blog-posts"
|
||||||
export * as FlashcardDecks from "./flashcard-decks"
|
export * as FlashcardDecks from "./flashcard-decks"
|
||||||
export * as Flashcards from "./flashcards"
|
export * as Flashcards from "./flashcards"
|
||||||
export * as Users from "./users"
|
export * as Users from "./users"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ShowSerializer } from "./show-serializer"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { pick } from "lodash"
|
||||||
|
|
||||||
|
import { UserPreference } from "@/models"
|
||||||
|
import BaseSerializer from "@/serializers/base-serializer"
|
||||||
|
|
||||||
|
export type UserPreferenceShowView = Pick<UserPreference, "key" | "value">
|
||||||
|
|
||||||
|
export class ShowSerializer extends BaseSerializer<UserPreference> {
|
||||||
|
perform(): UserPreferenceShowView {
|
||||||
|
return {
|
||||||
|
...pick(this.record, ["key", "value"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShowSerializer
|
||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
import { API_PORT, APPLICATION_NAME } from "@/config"
|
import { API_PORT, APPLICATION_NAME, GIT_COMMIT_HASH, RELEASE_TAG } from "@/config"
|
||||||
import logger from "@/utils/logger"
|
import logger from "@/utils/logger"
|
||||||
import app from "@/app"
|
import app from "@/app"
|
||||||
import { enqueueJobs } from "@/scheduler"
|
import { enqueueJobs } from "@/scheduler"
|
||||||
|
|
||||||
app.listen(API_PORT, async () => {
|
app.listen(API_PORT, async () => {
|
||||||
logger.info(`${APPLICATION_NAME} API listenting on port ${API_PORT}`)
|
logger.info(`${APPLICATION_NAME} API listenting on port ${API_PORT} (${RELEASE_TAG} ${GIT_COMMIT_HASH})`)
|
||||||
await enqueueJobs()
|
await enqueueJobs()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { CreationAttributes } from "@sequelize/core"
|
||||||
|
import { isNil } from "lodash"
|
||||||
|
|
||||||
|
import { BlogPost } from "@/models"
|
||||||
|
import BaseService from "@/services/base-service"
|
||||||
|
|
||||||
|
export type BlogPostCreationAttributes = Partial<CreationAttributes<BlogPost>>
|
||||||
|
|
||||||
|
export class CreateService extends BaseService {
|
||||||
|
constructor(private attributes: BlogPostCreationAttributes) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
async perform(): Promise<BlogPost> {
|
||||||
|
const { creatorId, title, slug, content, ...optionalAttributes } = this.attributes
|
||||||
|
|
||||||
|
if (isNil(creatorId)) {
|
||||||
|
throw new Error("Creator is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNil(title)) {
|
||||||
|
throw new Error("Title is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNil(slug)) {
|
||||||
|
throw new Error("Slug is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNil(content)) {
|
||||||
|
throw new Error("Content is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return BlogPost.create({
|
||||||
|
...optionalAttributes,
|
||||||
|
creatorId,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateService
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { BlogPost } from "@/models"
|
||||||
|
import BaseService from "@/services/base-service"
|
||||||
|
|
||||||
|
export class DestroyService extends BaseService {
|
||||||
|
constructor(private blogPost: BlogPost) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
async perform(): Promise<void> {
|
||||||
|
return this.blogPost.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DestroyService
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { CreateService } from "./create-service"
|
||||||
|
export { UpdateService } from "./update-service"
|
||||||
|
export { DestroyService } from "./destroy-service"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Attributes } from "@sequelize/core"
|
||||||
|
|
||||||
|
import { BlogPost } from "@/models"
|
||||||
|
import BaseService from "@/services/base-service"
|
||||||
|
|
||||||
|
export type BlogPostUpdateAttributes = Partial<Attributes<BlogPost>>
|
||||||
|
|
||||||
|
export class UpdateService extends BaseService {
|
||||||
|
constructor(
|
||||||
|
private blogPost: BlogPost,
|
||||||
|
private attributes: BlogPostUpdateAttributes
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
async perform(): Promise<BlogPost> {
|
||||||
|
return this.blogPost.update(this.attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UpdateService
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { FlashcardDeck } from "@/models"
|
import db from "@/db/db-client"
|
||||||
|
import { Flashcard, FlashcardDeck } from "@/models"
|
||||||
import BaseService from "@/services/base-service"
|
import BaseService from "@/services/base-service"
|
||||||
|
|
||||||
export class DestroyService extends BaseService {
|
export class DestroyService extends BaseService {
|
||||||
@@ -7,7 +8,20 @@ export class DestroyService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async perform(): Promise<void> {
|
async perform(): Promise<void> {
|
||||||
return this.flashcardDeck.destroy()
|
await db.transaction(async () => {
|
||||||
|
await FlashcardDeck.update(
|
||||||
|
{ parentDeckId: null },
|
||||||
|
{ where: { parentDeckId: this.flashcardDeck.id } }
|
||||||
|
)
|
||||||
|
|
||||||
|
await Flashcard.destroy({
|
||||||
|
where: {
|
||||||
|
flashcardDeckId: this.flashcardDeck.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.flashcardDeck.destroy()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * as BlogPosts from "./blog-posts"
|
||||||
export * as FlashcardDecks from "./flashcard-decks"
|
export * as FlashcardDecks from "./flashcard-decks"
|
||||||
export * as Flashcards from "./flashcards"
|
export * as Flashcards from "./flashcards"
|
||||||
export * as Users from "./users"
|
export * as Users from "./users"
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ export { DestroyService } from "./destroy-service"
|
|||||||
// Special Services
|
// Special Services
|
||||||
export { EnsureFromAuth0TokenService } from "./ensure-from-auth0-token-service"
|
export { EnsureFromAuth0TokenService } from "./ensure-from-auth0-token-service"
|
||||||
export { FindFromAuth0TokenService } from "./find-from-auth0-token-service"
|
export { FindFromAuth0TokenService } from "./find-from-auth0-token-service"
|
||||||
|
|
||||||
|
export * as Preferences from "./preferences"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { UpsertService } from "./upsert-service"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { User } from "@/models"
|
||||||
|
import UserPreference, { PreferenceKeys } from "@/models/user-preference"
|
||||||
|
import { preferenceValueValidators } from "@/models/user-preference-value-validators"
|
||||||
|
import BaseService from "@/services/base-service"
|
||||||
|
|
||||||
|
export class UpsertService extends BaseService {
|
||||||
|
constructor(
|
||||||
|
private user: User,
|
||||||
|
private key: string,
|
||||||
|
private value: unknown | null
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
async perform() {
|
||||||
|
const isPreferenceKey = Object.values(PreferenceKeys).includes(this.key as PreferenceKeys)
|
||||||
|
if (!isPreferenceKey) {
|
||||||
|
throw new Error("Provided key is not a valid PreferenceKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = this.key as PreferenceKeys
|
||||||
|
|
||||||
|
const validator = preferenceValueValidators[key]
|
||||||
|
if (!validator(this.value)) {
|
||||||
|
throw new Error("Invalid value for preference key")
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserPreference.setValue(this.user.id, key, this.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UpsertService
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { isFiniteNumber } from "./is-finite-number"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { isNumber } from "lodash"
|
||||||
|
|
||||||
|
export function isFiniteNumber(value: unknown): value is number {
|
||||||
|
return isNumber(value) && isFinite(value)
|
||||||
|
}
|
||||||
+2
-33
@@ -16,44 +16,13 @@ Note that the `dev` command uses the `db` service, and so only has access to fol
|
|||||||
|
|
||||||
## Set up `dev` command
|
## Set up `dev` command
|
||||||
|
|
||||||
The `dev` command vastly simplifies development using docker compose. It only requires `ruby`; however, `direnv` and `asdf` will make it easier to use.
|
The `dev` command vastly simplifies development using docker compose. It only requires `python`; however, `direnv` and `asdf` will make it easier to use.
|
||||||
|
|
||||||
It's simply a wrapper around docker compose with the ability to quickly add custom helpers.
|
It's simply a wrapper around docker compose with the ability to quickly add custom helpers.
|
||||||
|
|
||||||
All commands are just strings joined together, so it's easy to add new commmands. `dev` prints out each command that it runs, so that you can run the command manually to debug it, or just so you learn some docker compose syntax as you go.
|
All commands are just strings joined together, so it's easy to add new commmands. `dev` prints out each command that it runs, so that you can run the command manually to debug it, or just so you learn some docker compose syntax as you go.
|
||||||
|
|
||||||
1. (optional) Install `asdf` as seen in <https://asdf-vm.com/guide/getting-started.html>.
|
1. (optional) Install [direnv](https://direnv.net/) and create an `.envrc` with
|
||||||
|
|
||||||
e.g. for Linux
|
|
||||||
|
|
||||||
```bash
|
|
||||||
apt install curl git
|
|
||||||
|
|
||||||
git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.12.0
|
|
||||||
|
|
||||||
echo '
|
|
||||||
# asdf
|
|
||||||
. "$HOME/.asdf/asdf.sh"
|
|
||||||
. "$HOME/.asdf/completions/asdf.bash"
|
|
||||||
' >> ~/.bashrc
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Install `ruby` via `asdf` as seen here <https://github.com/asdf-vm/asdf-ruby>, or using whatever custom Ruby install method works for your platform.
|
|
||||||
|
|
||||||
e.g. for Linux
|
|
||||||
|
|
||||||
```bash
|
|
||||||
asdf plugin add ruby https://github.com/asdf-vm/asdf-ruby.git
|
|
||||||
|
|
||||||
# install version from .tool-versions file
|
|
||||||
asdf install ruby
|
|
||||||
|
|
||||||
asdf reshim ruby
|
|
||||||
```
|
|
||||||
|
|
||||||
You will now be able to run the `./bin/dev` command.
|
|
||||||
|
|
||||||
3. (optional) Install [direnv](https://direnv.net/) and create an `.envrc` with
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|||||||
@@ -1,295 +1,253 @@
|
|||||||
#!/usr/bin/env ruby
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
class DevHelper
|
import os
|
||||||
# Support dashes in command names
|
import re
|
||||||
COMMAND_TO_METHOD = {
|
import subprocess
|
||||||
"ts-node" => :ts_node,
|
import sys
|
||||||
"check-types" => :check_types,
|
|
||||||
"bash-completions" => :bash_completions,
|
|
||||||
"plantuml-to-png" => :plantuml_to_png,
|
|
||||||
}
|
|
||||||
METHOD_TO_COMMAND = COMMAND_TO_METHOD.invert
|
|
||||||
|
|
||||||
REPLACE_PROCESS = "replace_process"
|
# Support dashes in command names
|
||||||
WAIT_FOR_PROCESS = "wait_for_process"
|
COMMAND_TO_METHOD = {
|
||||||
|
"ts-node": "ts_node",
|
||||||
|
"check-types": "check_types",
|
||||||
|
"bash-completions": "bash_completions",
|
||||||
|
"plantuml-to-png": "plantuml_to_png",
|
||||||
|
}
|
||||||
|
METHOD_TO_COMMAND = {method: command for command, method in COMMAND_TO_METHOD.items()}
|
||||||
|
|
||||||
# External Interface
|
WAIT_FOR_PROCESS = "wait_for_process"
|
||||||
def self.call(*args)
|
|
||||||
new.call(*args)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Core logic
|
|
||||||
def call(*args, **kwargs)
|
|
||||||
command = args[0]
|
|
||||||
method = COMMAND_TO_METHOD.fetch(command, command)
|
|
||||||
if args.length.positive? && respond_to?(method)
|
|
||||||
public_send(method, *args.drop(1), **kwargs)
|
|
||||||
else
|
|
||||||
compose(*args, **kwargs)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def compose(*args, **kwargs)
|
class DevHelper:
|
||||||
command = compose_command(*args, **kwargs)
|
def __init__(self):
|
||||||
puts "Running: #{command}" unless kwargs[:slient]
|
self.last_exit_status = 0
|
||||||
|
|
||||||
case kwargs[:execution_mode]
|
# Core logic
|
||||||
when WAIT_FOR_PROCESS
|
def call(self, *args, **kwargs):
|
||||||
wait_for_process_with_logging(command)
|
if not args:
|
||||||
else
|
return self.compose(*args, **kwargs)
|
||||||
exec(command)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Primary command wrappers
|
command, rest = args[0], args[1:]
|
||||||
def build(*args, **kwargs)
|
method_name = COMMAND_TO_METHOD.get(command, command)
|
||||||
compose(%w[build], *args, **kwargs)
|
method = getattr(self, method_name, None)
|
||||||
end
|
if callable(method) and not method_name.startswith("_") and method_name != "call":
|
||||||
|
return method(*rest, **kwargs)
|
||||||
|
return self.compose(*args, **kwargs)
|
||||||
|
|
||||||
def compile(*args, **kwargs)
|
def compose(self, *args, **kwargs):
|
||||||
run(*%w[api npm run build], execution_mode: WAIT_FOR_PROCESS)
|
command = self._compose_command(*args, **kwargs)
|
||||||
exit($?.exitstatus) unless $?.success?
|
if not kwargs.get("silent"):
|
||||||
run(*%w[web npm run build])
|
print(f"Running: {command}")
|
||||||
end
|
|
||||||
|
|
||||||
def up(*args, **kwargs)
|
if kwargs.get("execution_mode") == WAIT_FOR_PROCESS:
|
||||||
compose(*%w[up --remove-orphans], *args, **kwargs)
|
self._wait_for_process_with_logging(command)
|
||||||
end
|
else:
|
||||||
|
os.execv("/bin/sh", ["/bin/sh", "-c", command])
|
||||||
|
|
||||||
def down(*args, **kwargs)
|
# Primary command wrappers
|
||||||
compose(*%w[down --remove-orphans], *args, **kwargs)
|
def build(self, *args, **kwargs):
|
||||||
end
|
self.compose("build", *args, **kwargs)
|
||||||
|
|
||||||
def logs(*args, **kwargs)
|
def compile(self, *args, **kwargs):
|
||||||
compose(*%w[logs -f], *args, **kwargs)
|
self.run("api", "npm", "run", "build", execution_mode=WAIT_FOR_PROCESS)
|
||||||
end
|
if self.last_exit_status != 0:
|
||||||
|
sys.exit(self.last_exit_status)
|
||||||
|
self.run("web", "npm", "run", "build")
|
||||||
|
|
||||||
def run(*args, **kwargs)
|
def up(self, *args, **kwargs):
|
||||||
compose(*%w[run --rm], *args, **kwargs)
|
self.compose("up", "--remove-orphans", *args, **kwargs)
|
||||||
end
|
|
||||||
|
|
||||||
def ps(*args, **kwargs)
|
def down(self, *args, **kwargs):
|
||||||
compose(*%w[ps], *args, **kwargs)
|
self.compose("down", "--remove-orphans", *args, **kwargs)
|
||||||
end
|
|
||||||
|
|
||||||
# Custom helpers
|
def logs(self, *args, **kwargs):
|
||||||
def api(*args, **kwargs)
|
self.compose("logs", "-f", *args, **kwargs)
|
||||||
run(*%w[api], *args, **kwargs)
|
|
||||||
end
|
|
||||||
|
|
||||||
def web(*args, **kwargs)
|
def run(self, *args, **kwargs):
|
||||||
run(*%w[web], *args, **kwargs)
|
self.compose("run", "--rm", *args, **kwargs)
|
||||||
end
|
|
||||||
|
|
||||||
def check_types(*args, **kwargs)
|
def ps(self, *args, **kwargs):
|
||||||
run(*%w[api npm run check-types], *args, **kwargs)
|
self.compose("ps", *args, **kwargs)
|
||||||
end
|
|
||||||
|
|
||||||
def test(*args, **kwargs)
|
# Custom helpers
|
||||||
service = args[0]
|
def api(self, *args, **kwargs):
|
||||||
if service == "api"
|
self.run("api", *args, **kwargs)
|
||||||
test_api(*args.drop(1), **kwargs)
|
|
||||||
elsif service == "web"
|
|
||||||
test_web(*args.drop(1), **kwargs)
|
|
||||||
else
|
|
||||||
test_api(*args, **kwargs)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_api(*args, **kwargs)
|
def web(self, *args, **kwargs):
|
||||||
reformat_project_relative_path_filter_for_vitest!(args, "api/")
|
self.run("web", *args, **kwargs)
|
||||||
run(*%w[test_api npm run test], *args, **kwargs)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_web(*args, **kwargs)
|
def check_types(self, *args, **kwargs):
|
||||||
reformat_project_relative_path_filter_for_vitest!(args, "web/")
|
self.run("api", "npm", "run", "check-types", *args, **kwargs)
|
||||||
run(*%w[test_web npm run test], *args, **kwargs)
|
|
||||||
end
|
|
||||||
|
|
||||||
def sqlcmd(*args, **kwargs)
|
def test(self, *args, **kwargs):
|
||||||
db_host = ENV.fetch('DB_HOST', 'localhost')
|
service = args[0] if args else None
|
||||||
db_user = ENV.fetch('DB_USER', 'sa')
|
if service == "api":
|
||||||
db_pass = ENV.fetch('DB_PASS', '1m5ecure!')
|
self.test_api(*args[1:], **kwargs)
|
||||||
db_name = ENV.fetch('DB_NAME', 'YHSI')
|
elif service == "web":
|
||||||
compose(
|
self.test_web(*args[1:], **kwargs)
|
||||||
*%w[exec db /opt/mssql-tools/bin/sqlcmd],
|
else:
|
||||||
*%W[-U #{db_user}],
|
self.test_api(*args, **kwargs)
|
||||||
*%W[-P #{db_pass}],
|
|
||||||
*%W[-H #{db_host}],
|
|
||||||
*%W[-d #{db_name}],
|
|
||||||
'-I', # enable quoted identifiers, e.g. "table"."column"
|
|
||||||
*args,
|
|
||||||
**kwargs
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def db(*args, **kwargs)
|
def test_api(self, *args, **kwargs):
|
||||||
compose(*%w[exec db], *args, **kwargs)
|
args = self._reformat_project_relative_path_filter_for_vitest(list(args), "api/")
|
||||||
end
|
self.run("test_api", "npm", "run", "test", *args, **kwargs)
|
||||||
|
|
||||||
def debug
|
def test_web(self, *args, **kwargs):
|
||||||
api_container_id = container_id("api")
|
args = self._reformat_project_relative_path_filter_for_vitest(list(args), "web/")
|
||||||
puts "Waiting for breakpoint to trigger..."
|
self.run("test_web", "npm", "run", "test", *args, **kwargs)
|
||||||
puts "'ctrl-c' to exit."
|
|
||||||
command = "docker attach --detach-keys ctrl-c #{api_container_id}"
|
|
||||||
puts "Running: #{command}"
|
|
||||||
exec(command)
|
|
||||||
exit 0
|
|
||||||
end
|
|
||||||
|
|
||||||
def npm(*args, **kwargs)
|
def sqlcmd(self, *args, **kwargs):
|
||||||
run(*%w[api npm], *args, **kwargs)
|
db_host = os.environ.get("DB_HOST", "localhost")
|
||||||
end
|
db_user = os.environ.get("DB_USER", "sa")
|
||||||
|
db_pass = os.environ.get("DB_PASS", "1m5ecure!")
|
||||||
|
db_name = os.environ.get("DB_NAME", "YHSI")
|
||||||
|
self.compose(
|
||||||
|
"exec",
|
||||||
|
"db",
|
||||||
|
"/opt/mssql-tools/bin/sqlcmd",
|
||||||
|
"-U",
|
||||||
|
db_user,
|
||||||
|
"-P",
|
||||||
|
db_pass,
|
||||||
|
"-H",
|
||||||
|
db_host,
|
||||||
|
"-d",
|
||||||
|
db_name,
|
||||||
|
"-I", # enable quoted identifiers, e.g. "table"."column"
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
def ts_node(*args, **kwargs)
|
def db(self, *args, **kwargs):
|
||||||
run(*%w[api npm run ts-node], *args, **kwargs)
|
self.compose("exec", "db", *args, **kwargs)
|
||||||
end
|
|
||||||
|
|
||||||
def knex(*args, **kwargs)
|
def debug(self, *args, **kwargs):
|
||||||
if RUBY_PLATFORM =~ /linux/
|
container_id = self._container_id("api")
|
||||||
run(*%w[api npm run knex], *args, execution_mode: WAIT_FOR_PROCESS, **kwargs)
|
print("Waiting for breakpoint to trigger...")
|
||||||
|
print("'ctrl-c' to exit.")
|
||||||
|
command = f"docker attach --detach-keys ctrl-c {container_id}"
|
||||||
|
print(f"Running: {command}")
|
||||||
|
os.execv("/bin/sh", ["/bin/sh", "-c", command])
|
||||||
|
|
||||||
file_or_directory = "#{project_root}/api/src/db/migrations"
|
def npm(self, *args, **kwargs):
|
||||||
exit(0) unless take_over_needed?(file_or_directory)
|
self.run("api", "npm", *args, **kwargs)
|
||||||
|
|
||||||
ownit file_or_directory
|
def ts_node(self, *args, **kwargs):
|
||||||
else
|
self.run("api", "npm", "run", "ts-node", *args, **kwargs)
|
||||||
run(*%w[api npm run knex], *args, **kwargs)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def migrate(*args, **kwargs)
|
def knex(self, *args, **kwargs):
|
||||||
action = args[0]
|
if sys.platform.startswith("linux"):
|
||||||
knex("migrate:#{action}", *args.drop(1), **kwargs)
|
self.run("api", "npm", "run", "knex", *args, execution_mode=WAIT_FOR_PROCESS, **kwargs)
|
||||||
end
|
|
||||||
|
|
||||||
def seed(*args, **kwargs)
|
file_or_directory = os.path.join(self._project_root(), "api/src/db/migrations")
|
||||||
action = args[0]
|
if not self._take_over_needed(file_or_directory):
|
||||||
knex("seed:#{action}", *args.drop(1), **kwargs)
|
sys.exit(0)
|
||||||
end
|
|
||||||
|
|
||||||
def ownit(*args, **kwargs)
|
self.ownit(file_or_directory)
|
||||||
file_or_directory = args[0]
|
else:
|
||||||
raise ScriptError, "Must provide a file or directory path." if file_or_directory.nil?
|
self.run("api", "npm", "run", "knex", *args, **kwargs)
|
||||||
|
|
||||||
if RUBY_PLATFORM =~ /linux/
|
def migrate(self, *args, **kwargs):
|
||||||
puts "Take ownership of the file or directory? #{file_or_directory}"
|
action = args[0] if args else None
|
||||||
exec("sudo chown -R #{user_id}:#{group_id} #{file_or_directory}")
|
self.knex(f"migrate:{action}", *args[1:], **kwargs)
|
||||||
else
|
|
||||||
raise NotImplementedError, "Not implement for platform #{RUBY_PLATFORM}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def plantuml_to_png(*args, **kwargs)
|
def seed(self, *args, **kwargs):
|
||||||
file_path = args.pop
|
action = args[0] if args else None
|
||||||
raise ScriptError, "Must provide a file path." if file_path.nil?
|
self.knex(f"seed:{action}", *args[1:], **kwargs)
|
||||||
|
|
||||||
png_path = file_path.gsub(/\.(wsd|pu|puml|plantuml|uml)$/, ".png")
|
def ownit(self, *args, **kwargs):
|
||||||
|
file_or_directory = args[0] if args else None
|
||||||
|
if file_or_directory is None:
|
||||||
|
raise ValueError("Must provide a file or directory path.")
|
||||||
|
|
||||||
command = <<~BASH
|
if sys.platform.startswith("linux"):
|
||||||
curl #{args.join(" ")} \
|
print(f"Take ownership of the file or directory? {file_or_directory}")
|
||||||
--data-binary @'#{file_path}' \
|
command = f"sudo chown -R {self._user_id()}:{self._group_id()} {file_or_directory}"
|
||||||
http://localhost:9999/png > '#{png_path}'
|
os.execv("/bin/sh", ["/bin/sh", "-c", command])
|
||||||
BASH
|
else:
|
||||||
|
raise NotImplementedError(f"Not implemented for platform {sys.platform}")
|
||||||
|
|
||||||
puts "Running: #{command}"
|
def plantuml_to_png(self, *args, **kwargs):
|
||||||
exec(command)
|
args = list(args)
|
||||||
end
|
if not args:
|
||||||
|
raise ValueError("Must provide a file path.")
|
||||||
|
file_path = args.pop()
|
||||||
|
|
||||||
def bash_completions
|
png_path = re.sub(r"\.(wsd|pu|puml|plantuml|uml)$", ".png", file_path)
|
||||||
completions =
|
|
||||||
public_methods(false)
|
|
||||||
.reject { |word| %i[call].include?(word) }
|
|
||||||
.map { |word| METHOD_TO_COMMAND.fetch(word, word) }
|
|
||||||
puts completions
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
command = f"curl {' '.join(args)} --data-binary @'{file_path}' http://localhost:9999/png > '{png_path}'"
|
||||||
|
|
||||||
def wait_for_process_with_logging(command)
|
print(f"Running: {command}")
|
||||||
IO.popen("#{command} 2>&1") do |io|
|
os.execv("/bin/sh", ["/bin/sh", "-c", command])
|
||||||
until io.eof?
|
|
||||||
line = io.gets
|
|
||||||
puts line
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def container_id(container_name, *args, **kwargs)
|
def bash_completions(self, *args, **kwargs):
|
||||||
command = compose_command(*%w[ps -q], container_name, *args, **kwargs)
|
completions = sorted(
|
||||||
puts "Running: #{command}"
|
METHOD_TO_COMMAND.get(name, name)
|
||||||
id_of_container = `#{command}`.chomp
|
for name in vars(DevHelper)
|
||||||
puts "Container id is: #{id_of_container}"
|
if not name.startswith("_") and name != "call" and callable(getattr(DevHelper, name))
|
||||||
id_of_container
|
)
|
||||||
end
|
print(" ".join(completions))
|
||||||
|
|
||||||
def service_running?(container_name)
|
# Private helpers
|
||||||
ps(*%w[-q --status=running], execution_mode: WAIT_FOR_PROCESS, slient: true) != ""
|
def _wait_for_process_with_logging(self, command):
|
||||||
end
|
process = subprocess.Popen(
|
||||||
|
["/bin/sh", "-c", f"{command} 2>&1"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
for line in process.stdout:
|
||||||
|
print(line, end="")
|
||||||
|
process.wait()
|
||||||
|
self.last_exit_status = process.returncode
|
||||||
|
|
||||||
def compose_command(*args, **kwargs)
|
def _container_id(self, container_name, *args, **kwargs):
|
||||||
environment = kwargs.fetch(:environment, "development")
|
command = self._compose_command("ps", "-q", container_name, *args, **kwargs)
|
||||||
"cd #{project_root} && docker compose -f docker-compose.#{environment}.yml #{args.join(" ")}"
|
print(f"Running: {command}")
|
||||||
end
|
result = subprocess.run(["/bin/sh", "-c", command], stdout=subprocess.PIPE, text=True)
|
||||||
|
container_id = result.stdout.strip()
|
||||||
|
print(f"Container id is: {container_id}")
|
||||||
|
return container_id
|
||||||
|
|
||||||
def project_root
|
def _service_running(self, container_name):
|
||||||
@project_root ||= File.absolute_path("#{__dir__}/..")
|
self.ps("-q", "--status=running", execution_mode=WAIT_FOR_PROCESS, silent=True)
|
||||||
end
|
return self.last_exit_status == 0
|
||||||
|
|
||||||
def take_over_needed?(file_or_directory)
|
def _compose_command(self, *args, **kwargs):
|
||||||
files_owned_by_others =
|
environment = kwargs.get("environment", "development")
|
||||||
system("find #{file_or_directory} -not -user #{user_id} -print -quit | grep -q .")
|
return f"cd {self._project_root()} && docker compose -f docker-compose.{environment}.yml {' '.join(args)}"
|
||||||
files_owned_by_others
|
|
||||||
end
|
|
||||||
|
|
||||||
def user_id
|
def _project_root(self):
|
||||||
unless RUBY_PLATFORM =~ /linux/
|
return os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
raise NotImplementedError, "Not implement for platform #{RUBY_PLATFORM}"
|
|
||||||
end
|
|
||||||
|
|
||||||
`id -u`.strip
|
def _take_over_needed(self, file_or_directory):
|
||||||
end
|
result = subprocess.run(
|
||||||
|
["/bin/sh", "-c", f"find {file_or_directory} -not -user {self._user_id()} -print -quit | grep -q ."],
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
def group_id
|
def _user_id(self):
|
||||||
unless RUBY_PLATFORM =~ /linux/
|
if not sys.platform.startswith("linux"):
|
||||||
raise NotImplementedError, "Not implement for platform #{RUBY_PLATFORM}"
|
raise NotImplementedError(f"Not implemented for platform {sys.platform}")
|
||||||
end
|
return subprocess.run(["id", "-u"], stdout=subprocess.PIPE, text=True).stdout.strip()
|
||||||
|
|
||||||
`id -g`.strip
|
def _group_id(self):
|
||||||
end
|
if not sys.platform.startswith("linux"):
|
||||||
|
raise NotImplementedError(f"Not implemented for platform {sys.platform}")
|
||||||
|
return subprocess.run(["id", "-g"], stdout=subprocess.PIPE, text=True).stdout.strip()
|
||||||
|
|
||||||
def reformat_project_relative_path_filter_for_vitest!(args, prefix)
|
def _reformat_project_relative_path_filter_for_vitest(self, args, prefix):
|
||||||
if args.length.positive? && args[0].start_with?(prefix)
|
if args and args[0].startswith(prefix):
|
||||||
src_path_prefix = "#{prefix}src/"
|
src_path_prefix = f"{prefix}src/"
|
||||||
test_path_regex = Regexp.escape(prefix)
|
|
||||||
src_path_regex = Regexp.escape(src_path_prefix)
|
|
||||||
|
|
||||||
if args[0].start_with?(src_path_prefix)
|
if args[0].startswith(src_path_prefix):
|
||||||
# TODO: handle other file types
|
# TODO: handle other file types
|
||||||
args[0] = args[0].gsub(/^#{src_path_regex}/, "tests/").gsub(/\.ts$/, ".test.ts")
|
args[0] = re.sub(r"\.ts$", ".test.ts", re.sub(f"^{re.escape(src_path_prefix)}", "tests/", args[0]))
|
||||||
else
|
else:
|
||||||
args[0] = args[0].gsub(/^#{test_path_regex}/, "")
|
args[0] = re.sub(f"^{re.escape(prefix)}", "", args[0])
|
||||||
end
|
|
||||||
|
|
||||||
puts "Reformatted path filter from project relative to service relative for vitest."
|
print("Reformatted path filter from project relative to service relative for vitest.")
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Only execute main function when file is executed
|
return args
|
||||||
DevHelper.call(*ARGV) if $PROGRAM_NAME == __FILE__
|
|
||||||
|
|
||||||
## Dev completions
|
|
||||||
# https://iridakos.com/programming/2018/03/01/bash-programmable-completion-tutorial
|
|
||||||
# _dev_completions () {
|
|
||||||
# local dev_command_path="$(which dev)"
|
|
||||||
# local dev_function_names
|
|
||||||
# dev_function_names="$(ruby "$dev_command_path" bash_completions)"
|
|
||||||
# # COMP_WORDS: an array of all the words typed after the name of the program the compspec belongs to
|
|
||||||
# # COMP_CWORD: an index of the COMP_WORDS array pointing to the word the current cursor is at - in other words, the index of the word the cursor was when the tab key was pressed
|
|
||||||
# # COMP_LINE: the current command line
|
|
||||||
# COMPREPLY=($(compgen -W "$dev_function_names" "${COMP_WORDS[$COMP_CWORD]}"))
|
|
||||||
# }
|
|
||||||
|
|
||||||
# complete -F _dev_completions dev
|
if __name__ == "__main__":
|
||||||
# complete -W "allow" direnv
|
DevHelper().call(*sys.argv[1:])
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Support dashes in command names
|
||||||
|
COMMAND_TO_METHOD = {}
|
||||||
|
METHOD_TO_COMMAND = {method: command for command, method in COMMAND_TO_METHOD.items()}
|
||||||
|
|
||||||
|
WAIT_FOR_PROCESS = "wait_for_process"
|
||||||
|
|
||||||
|
|
||||||
|
class ProdHelper:
|
||||||
|
def __init__(self):
|
||||||
|
self.last_exit_status = 0
|
||||||
|
|
||||||
|
# Core logic
|
||||||
|
def call(self, *args, **kwargs):
|
||||||
|
if not args:
|
||||||
|
return self.compose(*args, **kwargs)
|
||||||
|
|
||||||
|
command, rest = args[0], args[1:]
|
||||||
|
method_name = COMMAND_TO_METHOD.get(command, command)
|
||||||
|
method = getattr(self, method_name, None)
|
||||||
|
if callable(method) and not method_name.startswith("_") and method_name != "call":
|
||||||
|
return method(*rest, **kwargs)
|
||||||
|
return self.compose(*args, **kwargs)
|
||||||
|
|
||||||
|
def compose(self, *args, **kwargs):
|
||||||
|
command = self._compose_command(*args, **kwargs)
|
||||||
|
if not kwargs.get("silent"):
|
||||||
|
print(f"Running: {command}")
|
||||||
|
|
||||||
|
if kwargs.get("execution_mode") == WAIT_FOR_PROCESS:
|
||||||
|
self._wait_for_process_with_logging(command)
|
||||||
|
else:
|
||||||
|
os.execv("/bin/sh", ["/bin/sh", "-c", command])
|
||||||
|
|
||||||
|
# Primary command wrappers
|
||||||
|
def up(self, *args, **kwargs):
|
||||||
|
self.compose("up", "--remove-orphans", *args, **kwargs)
|
||||||
|
|
||||||
|
def down(self, *args, **kwargs):
|
||||||
|
self.compose("down", "--remove-orphans", *args, **kwargs)
|
||||||
|
|
||||||
|
def logs(self, *args, **kwargs):
|
||||||
|
self.compose("logs", "-f", *args, **kwargs)
|
||||||
|
|
||||||
|
def ps(self, *args, **kwargs):
|
||||||
|
self.compose("ps", *args, **kwargs)
|
||||||
|
|
||||||
|
def bash_completions(self, *args, **kwargs):
|
||||||
|
completions = sorted(
|
||||||
|
METHOD_TO_COMMAND.get(name, name)
|
||||||
|
for name in vars(ProdHelper)
|
||||||
|
if not name.startswith("_") and name != "call" and callable(getattr(ProdHelper, name))
|
||||||
|
)
|
||||||
|
print(" ".join(completions))
|
||||||
|
|
||||||
|
# Private helpers
|
||||||
|
def _wait_for_process_with_logging(self, command):
|
||||||
|
process = subprocess.Popen(
|
||||||
|
["/bin/sh", "-c", f"{command} 2>&1"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
for line in process.stdout:
|
||||||
|
print(line, end="")
|
||||||
|
process.wait()
|
||||||
|
self.last_exit_status = process.returncode
|
||||||
|
|
||||||
|
def _compose_command(self, *args, **kwargs):
|
||||||
|
environment = kwargs.get("environment", "production")
|
||||||
|
return f"cd {self._project_root()} && docker compose -f docker-compose.{environment}.yml {' '.join(args)}"
|
||||||
|
|
||||||
|
def _project_root(self):
|
||||||
|
return os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
ProdHelper().call(*sys.argv[1:])
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: gitea.burke.host/burkkyy/calebburke.dev:latest
|
||||||
|
pull_policy: always
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
ports:
|
||||||
|
- "${HOST_PORT:-3000}:${HOST_PORT:-3000}"
|
||||||
|
volumes:
|
||||||
|
- ./.env:/home/node/app/.env.production
|
||||||
+4
-4
@@ -20,13 +20,13 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
TZ: "UTC"
|
TZ: "UTC"
|
||||||
POSTGRES_USER: "${DB_USER}"
|
POSTGRES_USER: "${DB_USERNAME}"
|
||||||
POSTGRES_PASSWORD: "${DB_PASS}"
|
POSTGRES_PASSWORD: "${DB_PASSWORD}"
|
||||||
POSTGRES_DB: "${DB_NAME}"
|
POSTGRES_DB: "${DB_DATABASE}"
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pg-data:/var/lib/postgresql/data
|
- db_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
crossorigin
|
crossorigin
|
||||||
/>
|
/>
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Audiowide&family=Overpass:ital,wght@0,100..900;1,100..900&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Audiowide&family=Overpass:ital,wght@0,100..900;1,100..900&family=Source+Serif+4:opsz,wght@8..60,400;8..60,600;8..60,700&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
Generated
+17
-16
@@ -26,10 +26,10 @@
|
|||||||
"qs": "^6.14.0",
|
"qs": "^6.14.0",
|
||||||
"validator": "^13.15.26",
|
"validator": "^13.15.26",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-draggable-next": "^2.3.0",
|
|
||||||
"vue-i18n": "^11.3.0",
|
"vue-i18n": "^11.3.0",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "^4.6.4",
|
||||||
"vue-scrollto": "^2.20.0",
|
"vue-scrollto": "^2.20.0",
|
||||||
|
"vuedraggable": "^4.1.0",
|
||||||
"vuetify": "^3.11.4"
|
"vuetify": "^3.11.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -5171,11 +5171,10 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/sortablejs": {
|
"node_modules/sortablejs": {
|
||||||
"version": "1.15.7",
|
"version": "1.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
|
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
|
||||||
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
|
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
@@ -5739,16 +5738,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-draggable-next": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/vue-draggable-next/-/vue-draggable-next-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-ymbY0UIwfSdg0iDN/iyNNwUrTqZ/6KbPryzsvTNXBLuDCuOBdNijSK8yynNtmiSj6RapTPQfjLGQdJrZkzBd2w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"sortablejs": "^1.14.0",
|
|
||||||
"vue": "^3.5.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vue-eslint-parser": {
|
"node_modules/vue-eslint-parser": {
|
||||||
"version": "9.4.3",
|
"version": "9.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz",
|
||||||
@@ -5836,6 +5825,18 @@
|
|||||||
"typescript": ">=5.0.0"
|
"typescript": ">=5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vuedraggable": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sortablejs": "1.14.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vuetify": {
|
"node_modules/vuetify": {
|
||||||
"version": "3.12.8",
|
"version": "3.12.8",
|
||||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.12.8.tgz",
|
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.12.8.tgz",
|
||||||
|
|||||||
+1
-1
@@ -30,10 +30,10 @@
|
|||||||
"qs": "^6.14.0",
|
"qs": "^6.14.0",
|
||||||
"validator": "^13.15.26",
|
"validator": "^13.15.26",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-draggable-next": "^2.3.0",
|
|
||||||
"vue-i18n": "^11.3.0",
|
"vue-i18n": "^11.3.0",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "^4.6.4",
|
||||||
"vue-scrollto": "^2.20.0",
|
"vue-scrollto": "^2.20.0",
|
||||||
|
"vuedraggable": "^4.1.0",
|
||||||
"vuetify": "^3.11.4"
|
"vuetify": "^3.11.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import http from "@/api/http-client"
|
||||||
|
import { type FiltersOptions, type ModelOrder, type WhereOptions } from "@/api/base-api"
|
||||||
|
|
||||||
|
export type BlogPost = {
|
||||||
|
id: number
|
||||||
|
creatorId: number
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
content: string
|
||||||
|
tags: string[]
|
||||||
|
publishedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BlogPostIndexView = Omit<BlogPost, "content">
|
||||||
|
|
||||||
|
export type BlogPostWhereOptions = WhereOptions<BlogPost, "creatorId">
|
||||||
|
|
||||||
|
export type BlogPostFiltersOptions = FiltersOptions<{
|
||||||
|
search: string | string[]
|
||||||
|
}>
|
||||||
|
|
||||||
|
export type BlogPostQueryOptions = {
|
||||||
|
where?: BlogPostWhereOptions
|
||||||
|
filters?: BlogPostFiltersOptions
|
||||||
|
order?: ModelOrder[]
|
||||||
|
page?: number
|
||||||
|
perPage?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const blogPostsApi = {
|
||||||
|
async list(params: BlogPostQueryOptions = {}): Promise<{
|
||||||
|
blogPosts: BlogPostIndexView[]
|
||||||
|
totalCount: number
|
||||||
|
}> {
|
||||||
|
const { data } = await http.get("/api/blog-posts", { params })
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
async get(blogPostIdOrSlug: number | string): Promise<{
|
||||||
|
blogPost: BlogPost
|
||||||
|
}> {
|
||||||
|
const { data } = await http.get(`/api/blog-posts/${blogPostIdOrSlug}`)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
async create(attributes: Partial<BlogPost>): Promise<{
|
||||||
|
blogPost: BlogPost
|
||||||
|
}> {
|
||||||
|
const { data } = await http.post("/api/blog-posts", attributes)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
async update(
|
||||||
|
blogPostIdOrSlug: number | string,
|
||||||
|
attributes: Partial<BlogPost>
|
||||||
|
): Promise<{
|
||||||
|
blogPost: BlogPost
|
||||||
|
}> {
|
||||||
|
const { data } = await http.patch(`/api/blog-posts/${blogPostIdOrSlug}`, attributes)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
async delete(blogPostIdOrSlug: number | string): Promise<void> {
|
||||||
|
const { data } = await http.delete(`/api/blog-posts/${blogPostIdOrSlug}`)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default blogPostsApi
|
||||||
@@ -2,13 +2,16 @@ import http from "@/api/http-client"
|
|||||||
|
|
||||||
import { type Policy } from "@/api/base-api"
|
import { type Policy } from "@/api/base-api"
|
||||||
import { UserRoles, type User } from "@/api/users-api"
|
import { UserRoles, type User } from "@/api/users-api"
|
||||||
|
import { PreferenceAsShow } from "@/api/users/preferences-api"
|
||||||
|
|
||||||
export { UserRoles }
|
export { UserRoles }
|
||||||
|
|
||||||
export type UserAsShow = Pick<
|
export type UserAsShow = Pick<
|
||||||
User,
|
User,
|
||||||
"id" | "email" | "firstName" | "lastName" | "displayName" | "roles" | "createdAt" | "updatedAt"
|
"id" | "email" | "firstName" | "lastName" | "displayName" | "roles" | "createdAt" | "updatedAt"
|
||||||
>
|
> & {
|
||||||
|
preferences?: PreferenceAsShow[]
|
||||||
|
}
|
||||||
|
|
||||||
export const currentUserApi = {
|
export const currentUserApi = {
|
||||||
async get(): Promise<{
|
async get(): Promise<{
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ export const httpClient = axios.create({
|
|||||||
})
|
})
|
||||||
|
|
||||||
httpClient.interceptors.request.use(async (config) => {
|
httpClient.interceptors.request.use(async (config) => {
|
||||||
// Only add the Authorization header to requests that start with "/api"
|
// Only add the Authorization header to authenticated "/api" requests.
|
||||||
if (config.url?.startsWith("/api")) {
|
// "/api/public" routes are mounted ahead of the auth middleware and must
|
||||||
|
// stay reachable for anonymous visitors, so skip attaching a token there.
|
||||||
|
if (config.url?.startsWith("/api") && !config.url?.startsWith("/api/public")) {
|
||||||
const accessToken = await auth0.getAccessTokenSilently()
|
const accessToken = await auth0.getAccessTokenSilently()
|
||||||
config.headers["Authorization"] = `Bearer ${accessToken}`
|
config.headers["Authorization"] = `Bearer ${accessToken}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import http from "@/api/http-client"
|
||||||
|
import { type FiltersOptions, type ModelOrder, type WhereOptions } from "@/api/base-api"
|
||||||
|
|
||||||
|
export type PublicBlogPost = {
|
||||||
|
id: number
|
||||||
|
creatorId: number
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
content: string
|
||||||
|
tags: string[]
|
||||||
|
publishedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicBlogPostIndexView = Omit<PublicBlogPost, "content">
|
||||||
|
|
||||||
|
export type PublicBlogPostWhereOptions = WhereOptions<PublicBlogPost, "creatorId">
|
||||||
|
|
||||||
|
export type PublicBlogPostFiltersOptions = FiltersOptions<{
|
||||||
|
search: string | string[]
|
||||||
|
}>
|
||||||
|
|
||||||
|
export type PublicBlogPostQueryOptions = {
|
||||||
|
where?: PublicBlogPostWhereOptions
|
||||||
|
filters?: PublicBlogPostFiltersOptions
|
||||||
|
order?: ModelOrder[]
|
||||||
|
page?: number
|
||||||
|
perPage?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publicBlogPostsApi = {
|
||||||
|
async list(params: PublicBlogPostQueryOptions = {}): Promise<{
|
||||||
|
blogPosts: PublicBlogPostIndexView[]
|
||||||
|
totalCount: number
|
||||||
|
}> {
|
||||||
|
const { data } = await http.get("/api/public/blog-posts", { params })
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
async get(blogPostIdOrSlug: number | string): Promise<{
|
||||||
|
blogPost: PublicBlogPost
|
||||||
|
}> {
|
||||||
|
const { data } = await http.get(`/api/public/blog-posts/${blogPostIdOrSlug}`)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default publicBlogPostsApi
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { publicBlogPostsApi } from "./blog-posts-api"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { preferencesApi } from "./preferences-api"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import http from "@/api/http-client"
|
||||||
|
|
||||||
|
/** Keep in sync with api/src/models/user-preference.ts */
|
||||||
|
export enum PreferenceKeys {
|
||||||
|
FLASHCARDS_DAY_STREAK = "flashcards.day_streak",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PreferenceValueMap = {
|
||||||
|
[PreferenceKeys.FLASHCARDS_DAY_STREAK]: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Preference = {
|
||||||
|
key: PreferenceKeys
|
||||||
|
value: unknown | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PreferenceAsShow = Preference
|
||||||
|
|
||||||
|
export const preferencesApi = {
|
||||||
|
async upsert<K extends PreferenceKeys>(
|
||||||
|
userId: number,
|
||||||
|
key: K,
|
||||||
|
value: PreferenceValueMap[K] | null
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
const { data } = await http.patch(`/api/users/${userId}/preferences/${key}`, {
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default preferencesApi
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<v-card
|
||||||
|
flat
|
||||||
|
color="transparent"
|
||||||
|
class="post-card"
|
||||||
|
>
|
||||||
|
<v-card-text class="post">
|
||||||
|
<div class="post-date">
|
||||||
|
<span class="post-date-day font-weight-bold">{{
|
||||||
|
formatDate(blogPost.publishedAt, "dd")
|
||||||
|
}}</span>
|
||||||
|
<span>{{ formatDate(blogPost.publishedAt, "MMM yyyy") }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="post-content">
|
||||||
|
<div class="post-main">
|
||||||
|
<h3 class="font-serif post-title">
|
||||||
|
{{ blogPost.title }}
|
||||||
|
</h3>
|
||||||
|
<p class="font-serif post-excerpt">
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio praesent
|
||||||
|
libero sed cursus ante dapibus diam.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="post-footer">
|
||||||
|
<span class="post-read-time">5 min read</span>
|
||||||
|
<div
|
||||||
|
v-if="blogPost.tags.length"
|
||||||
|
class="post-tags ml-2"
|
||||||
|
>
|
||||||
|
<v-chip
|
||||||
|
v-for="tag in blogPost.tags"
|
||||||
|
:key="tag"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
class="post-tag ml-2"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { toRefs } from "vue"
|
||||||
|
|
||||||
|
import { BlogPostIndexView } from "@/api/blog-posts-api"
|
||||||
|
import { formatDate } from "@/utils/formatters"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
blogPost: BlogPostIndexView
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { blogPost } = toRefs(props)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.post {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 76px 1fr;
|
||||||
|
gap: 32px;
|
||||||
|
padding: 18px 0;
|
||||||
|
min-height: 180px;
|
||||||
|
height: 100%;
|
||||||
|
border-bottom: 1px solid rgb(var(--v-theme-borderColor));
|
||||||
|
}
|
||||||
|
|
||||||
|
.post:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-date {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: ui-monospace, Menlo, Monaco, Consolas, "Ubuntu Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgb(var(--v-theme-textSecondary) / 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-date-day {
|
||||||
|
display: block;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 100%;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-main {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-excerpt {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgb(var(--v-theme-textSecondary) / 65%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-tag {
|
||||||
|
font-family: ui-monospace, Menlo, Monaco, Consolas, "Ubuntu Mono", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<v-skeleton-loader
|
||||||
|
v-if="isEmpty(publicBlogPosts) && isLoading"
|
||||||
|
type="list-item-three-line"
|
||||||
|
/>
|
||||||
|
<v-empty-state
|
||||||
|
v-else-if="isEmpty(publicBlogPosts) && !isLoading"
|
||||||
|
text="No blog posts found."
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<BlogPostListItemCard
|
||||||
|
v-for="blogPost in publicBlogPosts"
|
||||||
|
:key="blogPost.id"
|
||||||
|
:blog-post="blogPost"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<EnhancedPagination
|
||||||
|
v-model="page"
|
||||||
|
v-model:per-page="perPage"
|
||||||
|
:total-count="totalCount"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { isEmpty } from "lodash"
|
||||||
|
|
||||||
|
import useRouteQueryPagination from "@/use/utils/use-route-query-pagination"
|
||||||
|
import usePublicBlogPosts, {
|
||||||
|
type PublicBlogPostFiltersOptions,
|
||||||
|
type PublicBlogPostQueryOptions,
|
||||||
|
type PublicBlogPostWhereOptions,
|
||||||
|
} from "@/use/use-public-blog-posts"
|
||||||
|
|
||||||
|
import EnhancedPagination from "@/components/common/EnhancedPagination.vue"
|
||||||
|
import BlogPostListItemCard from "@/components/blog-posts/BlogPostListItemCard.vue"
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
filters?: PublicBlogPostFiltersOptions
|
||||||
|
where?: PublicBlogPostWhereOptions
|
||||||
|
routeQuerySuffix?: string
|
||||||
|
waiting?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
filters: () => ({}),
|
||||||
|
where: () => ({}),
|
||||||
|
routeQuerySuffix: "BlogPosts",
|
||||||
|
waiting: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { page, perPage } = useRouteQueryPagination({ routeQuerySuffix: props.routeQuerySuffix })
|
||||||
|
const blogPostQueryOptions = computed<PublicBlogPostQueryOptions>(() => ({
|
||||||
|
where: props.where,
|
||||||
|
filters: props.filters,
|
||||||
|
page: page.value,
|
||||||
|
perPage: perPage.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { publicBlogPosts, totalCount, isLoading, refresh } = usePublicBlogPosts(
|
||||||
|
blogPostQueryOptions,
|
||||||
|
{ skipWatchIf: () => props.waiting }
|
||||||
|
)
|
||||||
|
|
||||||
|
defineExpose({ refresh })
|
||||||
|
</script>
|
||||||
@@ -4,17 +4,7 @@
|
|||||||
to="/dashboard"
|
to="/dashboard"
|
||||||
class="d-flex"
|
class="d-flex"
|
||||||
>
|
>
|
||||||
<!-- <img
|
<div class="d-flex">
|
||||||
class="ml-0 mt-1"
|
|
||||||
style="height: 36px"
|
|
||||||
:src="AppLogo"
|
|
||||||
/> -->
|
|
||||||
<div v-if="sidebarMini || mdAndDown"></div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="d-flex"
|
|
||||||
style="width: 200px"
|
|
||||||
>
|
|
||||||
<h1
|
<h1
|
||||||
class="mt-1 ml-3"
|
class="mt-1 ml-3"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -27,20 +17,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { useDisplay } from "vuetify"
|
|
||||||
import useInterface from "@/use/use-interface"
|
|
||||||
import { watch } from "vue"
|
|
||||||
|
|
||||||
// import AppLogo from "@/assets/app_logo_small.png"
|
|
||||||
import { APPLICATION_NAME } from "@/config"
|
import { APPLICATION_NAME } from "@/config"
|
||||||
|
|
||||||
const { sidebarMini, setSidebarMini } = useInterface()
|
|
||||||
const { mdAndDown } = useDisplay()
|
|
||||||
|
|
||||||
watch(mdAndDown, (newVal) => {
|
|
||||||
if (newVal === true) setSidebarMini(false)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<v-card
|
||||||
|
elevation="8"
|
||||||
|
hover
|
||||||
|
>
|
||||||
|
<v-card-item>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-avatar
|
||||||
|
:color="color"
|
||||||
|
size="56"
|
||||||
|
class="mr-4"
|
||||||
|
>
|
||||||
|
<slot name="icon"></slot>
|
||||||
|
</v-avatar>
|
||||||
|
<div>
|
||||||
|
<v-card-title class="text-h5">{{ title }}</v-card-title>
|
||||||
|
<v-card-subtitle>{{ subtitle }}</v-card-subtitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-item>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center justify-space-between">
|
||||||
|
<span class="text-h3 font-weight-bold">{{ count ?? " " }}</span>
|
||||||
|
<v-icon
|
||||||
|
size="large"
|
||||||
|
:color="color"
|
||||||
|
>
|
||||||
|
mdi-chevron-right
|
||||||
|
</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-medium-emphasis mt-1">{{ countLabel ?? " " }}</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
count?: number | null
|
||||||
|
countLabel?: string | null
|
||||||
|
color?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
subtitle: "",
|
||||||
|
count: null,
|
||||||
|
countLabel: null,
|
||||||
|
color: "primary",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<v-pagination
|
||||||
|
v-model="page"
|
||||||
|
:length="totalPages"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Use in conjuction with `useRouteQueryPagination` composable.
|
||||||
|
*/
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
const page = defineModel<number>({
|
||||||
|
required: true,
|
||||||
|
default: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const perPage = defineModel<number>("perPage", {
|
||||||
|
default: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
totalCount: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.ceil(props.totalCount / perPage.value))
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<div class="logo">
|
||||||
|
<RouterLink
|
||||||
|
:to="to"
|
||||||
|
class="d-flex"
|
||||||
|
>
|
||||||
|
<div class="d-flex align-center mt-1 ml-3 terminal-prompt">
|
||||||
|
<span class="term-green">caleb</span>
|
||||||
|
<span class="term-cyan">@</span>
|
||||||
|
<span class="term-green mr-2">burke</span>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
to?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
to: "/dashboard",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.logo a {
|
||||||
|
text-decoration: none !important;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-prompt {
|
||||||
|
font-family: ui-monospace, Menlo, Monaco, Consolas, "Ubuntu Mono", "DejaVu Sans Mono", monospace;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-prompt .term-green {
|
||||||
|
color: #4e9a06;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-prompt .term-cyan {
|
||||||
|
color: #06989a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-prompt .term-white {
|
||||||
|
color: #d3d7cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-prompt .term-cursor {
|
||||||
|
color: #d3d7cf;
|
||||||
|
animation: term-blink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes term-blink {
|
||||||
|
0%,
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50.01%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
v-model="showDialog"
|
||||||
|
width="400"
|
||||||
|
>
|
||||||
|
<v-form
|
||||||
|
ref="formRef"
|
||||||
|
@submit.prevent="validateAndCreate"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Create New Flashcard Deck</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-text-field
|
||||||
|
v-model="flashcardDeck.name"
|
||||||
|
label="Name"
|
||||||
|
:rules="[required]"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-btn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
text="Create"
|
||||||
|
/>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-form>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
|
import { VForm } from "vuetify/components"
|
||||||
|
|
||||||
|
import { required } from "@/utils/validators"
|
||||||
|
import { booleanTransformer } from "@/utils/use-route-query-transformers"
|
||||||
|
|
||||||
|
import useSnack from "@/use/use-snack"
|
||||||
|
import flashcardDecksApi, { FlashcardDeck } from "@/api/flashcard-decks-api"
|
||||||
|
|
||||||
|
const flashcardDeck = ref<Partial<FlashcardDeck>>({})
|
||||||
|
|
||||||
|
const showDialog = useRouteQuery<string, boolean>("showFlashcardDeckCreateDialog", "false", {
|
||||||
|
transform: booleanTransformer,
|
||||||
|
})
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
flashcardDeck.value = {}
|
||||||
|
showDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
showDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{ created: [flashcardDeckId: number] }>()
|
||||||
|
|
||||||
|
const isCreating = ref(false)
|
||||||
|
const formRef = ref<InstanceType<typeof VForm> | null>(null)
|
||||||
|
const snack = useSnack()
|
||||||
|
|
||||||
|
async function validateAndCreate() {
|
||||||
|
if (formRef.value === null) return
|
||||||
|
|
||||||
|
const { valid } = await formRef.value.validate()
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
isCreating.value = true
|
||||||
|
const { flashcardDeck: newFlashcardDeck } = await flashcardDecksApi.create(flashcardDeck.value)
|
||||||
|
emit("created", newFlashcardDeck.id)
|
||||||
|
close()
|
||||||
|
snack.success("Flashcard Deck Created")
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
snack.error("Failed to create Flashcard Deck")
|
||||||
|
} finally {
|
||||||
|
isCreating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
show,
|
||||||
|
close,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<template>
|
||||||
|
<v-menu>
|
||||||
|
<template #activator="{ props: menuProps }">
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
density="comfortable"
|
||||||
|
size="default"
|
||||||
|
v-bind="menuProps"
|
||||||
|
>
|
||||||
|
<v-icon icon="mdi-dots-vertical" />
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-book-open-blank-variant-outline"
|
||||||
|
title="Start Review"
|
||||||
|
@click="goToReviewPage"
|
||||||
|
/>
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-folder-plus-outline"
|
||||||
|
title="Add subdeck"
|
||||||
|
@click="openAddSubdeck"
|
||||||
|
/>
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-pencil-outline"
|
||||||
|
title="Edit deck"
|
||||||
|
@click="openEdit"
|
||||||
|
/>
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-delete-outline"
|
||||||
|
title="Delete this deck"
|
||||||
|
class="text-error"
|
||||||
|
@click="openDelete"
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
|
<v-dialog
|
||||||
|
v-model="showAddSubdeck"
|
||||||
|
width="400"
|
||||||
|
>
|
||||||
|
<v-form
|
||||||
|
ref="addFormRef"
|
||||||
|
@submit.prevent="createSubdeck"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Add Sub-Deck</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field
|
||||||
|
v-model="newDeckName"
|
||||||
|
label="Name"
|
||||||
|
autofocus
|
||||||
|
:rules="[required]"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
text="Cancel"
|
||||||
|
@click="showAddSubdeck = false"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
text="Create"
|
||||||
|
/>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-form>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-dialog
|
||||||
|
v-model="showEdit"
|
||||||
|
width="400"
|
||||||
|
>
|
||||||
|
<v-form
|
||||||
|
ref="editFormRef"
|
||||||
|
@submit.prevent="updateDeck"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Edit Deck</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field
|
||||||
|
v-model="editName"
|
||||||
|
label="Name"
|
||||||
|
autofocus
|
||||||
|
:rules="[required]"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
text="Cancel"
|
||||||
|
@click="showEdit = false"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
text="Save"
|
||||||
|
/>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-form>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-dialog
|
||||||
|
v-model="showDelete"
|
||||||
|
width="400"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Delete Deck</v-card-title>
|
||||||
|
<v-card-text>Are you sure you want to delete this deck?</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
text="Cancel"
|
||||||
|
@click="showDelete = false"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
variant="flat"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
text="Delete"
|
||||||
|
@click="deleteDeck"
|
||||||
|
/>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { VForm } from "vuetify/components"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
|
||||||
|
import { required } from "@/utils/validators"
|
||||||
|
import flashcardDecksApi from "@/api/flashcard-decks-api"
|
||||||
|
import useSnack from "@/use/use-snack"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
flashcardDeckId: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
created: [flashcardDeckId: number]
|
||||||
|
updated: [flashcardDeckId: number]
|
||||||
|
deleted: [flashcardDeckId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const snack = useSnack()
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function goToReviewPage() {
|
||||||
|
router.push({
|
||||||
|
name: "FlashcardDeckReviewPage",
|
||||||
|
params: {
|
||||||
|
flashcardDeckId: props.flashcardDeckId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add subdeck
|
||||||
|
const showAddSubdeck = ref(false)
|
||||||
|
const newDeckName = ref("")
|
||||||
|
const addFormRef = ref<InstanceType<typeof VForm> | null>(null)
|
||||||
|
|
||||||
|
function openAddSubdeck() {
|
||||||
|
newDeckName.value = ""
|
||||||
|
showAddSubdeck.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSubdeck() {
|
||||||
|
if (!addFormRef.value) return
|
||||||
|
const { valid } = await addFormRef.value.validate()
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSubmitting.value = true
|
||||||
|
const { flashcardDeck } = await flashcardDecksApi.create({
|
||||||
|
name: newDeckName.value,
|
||||||
|
parentDeckId: props.flashcardDeckId,
|
||||||
|
})
|
||||||
|
emit("created", flashcardDeck.id)
|
||||||
|
showAddSubdeck.value = false
|
||||||
|
snack.success("Sub-deck created")
|
||||||
|
} catch {
|
||||||
|
snack.error("Failed to create sub-deck")
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit deck
|
||||||
|
const showEdit = ref(false)
|
||||||
|
const editName = ref("")
|
||||||
|
const editFormRef = ref<InstanceType<typeof VForm> | null>(null)
|
||||||
|
|
||||||
|
async function openEdit() {
|
||||||
|
try {
|
||||||
|
const { flashcardDeck } = await flashcardDecksApi.get(props.flashcardDeckId)
|
||||||
|
editName.value = flashcardDeck.name
|
||||||
|
showEdit.value = true
|
||||||
|
} catch {
|
||||||
|
snack.error("Failed to load deck")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateDeck() {
|
||||||
|
if (!editFormRef.value) return
|
||||||
|
const { valid } = await editFormRef.value.validate()
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSubmitting.value = true
|
||||||
|
await flashcardDecksApi.update(props.flashcardDeckId, { name: editName.value })
|
||||||
|
emit("updated", props.flashcardDeckId)
|
||||||
|
showEdit.value = false
|
||||||
|
snack.success("Deck updated")
|
||||||
|
} catch {
|
||||||
|
snack.error("Failed to update deck")
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deck
|
||||||
|
const showDelete = ref(false)
|
||||||
|
|
||||||
|
function openDelete() {
|
||||||
|
showDelete.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDeck() {
|
||||||
|
try {
|
||||||
|
isSubmitting.value = true
|
||||||
|
await flashcardDecksApi.delete(props.flashcardDeckId)
|
||||||
|
emit("deleted", props.flashcardDeckId)
|
||||||
|
showDelete.value = false
|
||||||
|
snack.success("Deck deleted")
|
||||||
|
} catch {
|
||||||
|
snack.error("Failed to delete deck")
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<v-btn
|
||||||
|
class="ml-3"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-book-open-blank-variant-outline"
|
||||||
|
text="Start Review"
|
||||||
|
@click="startReview"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
|
||||||
|
const props = defineProps<{ flashcardDeckId: number }>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function startReview() {
|
||||||
|
goToReviewPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToReviewPage() {
|
||||||
|
router.push({
|
||||||
|
name: "FlashcardDeckReviewPage",
|
||||||
|
params: {
|
||||||
|
flashcardDeckId: props.flashcardDeckId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center justify-space-between mb-1">
|
||||||
|
<span class="title">Decks</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="d-flex justify-center py-4"
|
||||||
|
>
|
||||||
|
<v-progress-circular
|
||||||
|
indeterminate
|
||||||
|
size="24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-if="tree.length === 0"
|
||||||
|
class="text-center py-6 text-body-2 text-medium-emphasis"
|
||||||
|
>
|
||||||
|
No decks yet. Create one to get started.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<draggable
|
||||||
|
v-model="tree"
|
||||||
|
:group="{ name: 'decks' }"
|
||||||
|
item-key="id"
|
||||||
|
:delay="150"
|
||||||
|
:delay-on-touch-only="true"
|
||||||
|
@change="onRootChange"
|
||||||
|
>
|
||||||
|
<template #item="{ element }">
|
||||||
|
<FlashcardDeckTreeNode
|
||||||
|
v-model:children="element.children"
|
||||||
|
:deck="element"
|
||||||
|
:selected-id="selectedId"
|
||||||
|
@select="onSelect"
|
||||||
|
@move="handleMove"
|
||||||
|
@created="fetch()"
|
||||||
|
@updated="onDeckUpdated"
|
||||||
|
@deleted="onDeckDeleted"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
class="ml-2"
|
||||||
|
variant="text"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
text="Add Deck"
|
||||||
|
@click="openCreateDialog"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-dialog
|
||||||
|
v-model="showCreateDialog"
|
||||||
|
width="400"
|
||||||
|
>
|
||||||
|
<v-form
|
||||||
|
ref="formRef"
|
||||||
|
@submit.prevent="createDeck"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Add Deck</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field
|
||||||
|
v-model="newDeckName"
|
||||||
|
label="Name"
|
||||||
|
autofocus
|
||||||
|
:rules="[required]"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
text="Cancel"
|
||||||
|
@click="showCreateDialog = false"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
:loading="isCreating"
|
||||||
|
text="Create"
|
||||||
|
/>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-form>
|
||||||
|
</v-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { FlashcardDeck } from "@/api/flashcard-decks-api"
|
||||||
|
|
||||||
|
export type DeckNode = FlashcardDeck & { children: DeckNode[] }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue"
|
||||||
|
import draggable from "vuedraggable"
|
||||||
|
import { VForm } from "vuetify/components"
|
||||||
|
|
||||||
|
import flashcardDecksApi from "@/api/flashcard-decks-api"
|
||||||
|
import { required } from "@/utils/validators"
|
||||||
|
|
||||||
|
import useFlashcardDecks from "@/use/use-flashcard-decks"
|
||||||
|
import useSnack from "@/use/use-snack"
|
||||||
|
|
||||||
|
import FlashcardDeckTreeNode from "@/components/flashcard-decks/FlashcardDeckTreeNode.vue"
|
||||||
|
|
||||||
|
const { flashcardDecks, isLoading, fetch } = useFlashcardDecks()
|
||||||
|
|
||||||
|
const tree = ref<DeckNode[]>([])
|
||||||
|
|
||||||
|
function buildTreeOptimized(decks: FlashcardDeck[]): DeckNode[] {
|
||||||
|
const map = new Map<number, DeckNode>()
|
||||||
|
const roots: DeckNode[] = []
|
||||||
|
|
||||||
|
decks.forEach((d) => map.set(d.id, { ...d, children: [] }))
|
||||||
|
|
||||||
|
decks.forEach((d) => {
|
||||||
|
const node = map.get(d.id)!
|
||||||
|
if (d.parentDeckId === null) {
|
||||||
|
roots.push(node)
|
||||||
|
} else {
|
||||||
|
const parent = map.get(d.parentDeckId)
|
||||||
|
if (parent) parent.children.push(node)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
flashcardDecks,
|
||||||
|
(newDecks) => {
|
||||||
|
tree.value = buildTreeOptimized(newDecks)
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Selection ---
|
||||||
|
|
||||||
|
const selectedId = ref<number | null>(null)
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [deck: DeckNode]
|
||||||
|
updated: [flashcardDeckId: number]
|
||||||
|
deleted: [flashcardDeckId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function onSelect(deck: DeckNode) {
|
||||||
|
selectedId.value = deck.id
|
||||||
|
emit("select", deck)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeckUpdated(flashcardDeckId: number) {
|
||||||
|
await fetch()
|
||||||
|
emit("updated", flashcardDeckId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeckDeleted(flashcardDeckId: number) {
|
||||||
|
await fetch()
|
||||||
|
emit("deleted", flashcardDeckId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Drag & Drop ---
|
||||||
|
|
||||||
|
const snack = useSnack()
|
||||||
|
|
||||||
|
async function handleMove({ deckId, newParentId }: { deckId: number; newParentId: number | null }) {
|
||||||
|
try {
|
||||||
|
await flashcardDecksApi.update(deckId, { parentDeckId: newParentId })
|
||||||
|
// await fetch()
|
||||||
|
} catch {
|
||||||
|
snack.error("Failed to move deck")
|
||||||
|
tree.value = buildTreeOptimized(flashcardDecks.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRootChange(event: { added?: { element: DeckNode } }) {
|
||||||
|
if (event.added) {
|
||||||
|
handleMove({ deckId: event.added.element.id, newParentId: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Create ---
|
||||||
|
|
||||||
|
const showCreateDialog = ref(false)
|
||||||
|
const newDeckName = ref("")
|
||||||
|
const isCreating = ref(false)
|
||||||
|
const formRef = ref<InstanceType<typeof VForm> | null>(null)
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
newDeckName.value = ""
|
||||||
|
showCreateDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDeck() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
const { valid } = await formRef.value.validate()
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
isCreating.value = true
|
||||||
|
await flashcardDecksApi.create({
|
||||||
|
name: newDeckName.value,
|
||||||
|
parentDeckId: null,
|
||||||
|
})
|
||||||
|
showCreateDialog.value = false
|
||||||
|
await fetch()
|
||||||
|
} catch {
|
||||||
|
snack.error("Failed to create deck")
|
||||||
|
} finally {
|
||||||
|
isCreating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ refresh: fetch })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="deck-node d-flex align-center px-3 py-2 rounded"
|
||||||
|
:class="{ selected: isSelected }"
|
||||||
|
@click="$emit('select', deck)"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
density="comfortable"
|
||||||
|
size="default"
|
||||||
|
class="mr-1"
|
||||||
|
@click.stop="expanded = !expanded"
|
||||||
|
>
|
||||||
|
<v-icon size="default">
|
||||||
|
{{ expanded ? "mdi-chevron-down" : "mdi-chevron-right" }}
|
||||||
|
</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-icon
|
||||||
|
size="default"
|
||||||
|
class="mr-2 text-medium-emphasis"
|
||||||
|
:icon="expanded && hasChildren ? 'mdi-folder-open-outline' : 'mdi-folder-outline'"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="flex-grow-1 text-body-1"
|
||||||
|
:class="{ 'font-weight-medium': isSelected }"
|
||||||
|
>
|
||||||
|
{{ deck.name }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="kebab-btn"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<FlashcardDeckKebab
|
||||||
|
:flashcard-deck-id="deck.id"
|
||||||
|
@created="$emit('created', $event)"
|
||||||
|
@updated="$emit('updated', $event)"
|
||||||
|
@deleted="$emit('deleted', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-show="expanded"
|
||||||
|
class="pl-5"
|
||||||
|
>
|
||||||
|
<draggable
|
||||||
|
v-model="children"
|
||||||
|
:group="{ name: 'decks' }"
|
||||||
|
item-key="id"
|
||||||
|
:delay="150"
|
||||||
|
:delay-on-touch-only="true"
|
||||||
|
@change="onChange"
|
||||||
|
>
|
||||||
|
<template #item="{ element }">
|
||||||
|
<FlashcardDeckTreeNode
|
||||||
|
v-model:children="element.children"
|
||||||
|
:deck="element"
|
||||||
|
:selected-id="selectedId"
|
||||||
|
@select="$emit('select', $event)"
|
||||||
|
@move="$emit('move', $event)"
|
||||||
|
@created="$emit('created', $event)"
|
||||||
|
@updated="$emit('updated', $event)"
|
||||||
|
@deleted="$emit('deleted', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from "vue"
|
||||||
|
import draggable from "vuedraggable"
|
||||||
|
|
||||||
|
import type { DeckNode } from "@/components/flashcard-decks/FlashcardDeckTree.vue"
|
||||||
|
import FlashcardDeckKebab from "@/components/flashcard-decks/FlashcardDeckKebab.vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
deck: DeckNode
|
||||||
|
selectedId?: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const children = defineModel<DeckNode[]>("children", { required: true })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [deck: DeckNode]
|
||||||
|
move: [payload: { deckId: number; newParentId: number | null }]
|
||||||
|
created: [flashcardDeckId: number]
|
||||||
|
updated: [flashcardDeckId: number]
|
||||||
|
deleted: [flashcardDeckId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const expanded = ref(false)
|
||||||
|
const isSelected = computed(() => props.selectedId === props.deck.id)
|
||||||
|
const hasChildren = computed(() => children.value.length > 0)
|
||||||
|
|
||||||
|
function onChange(event: { added?: { element: DeckNode } }) {
|
||||||
|
if (event.added) {
|
||||||
|
emit("move", { deckId: event.added.element.id, newParentId: props.deck.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.deck-node {
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-node:hover {
|
||||||
|
background: rgb(var(--v-theme-hoverColor));
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-node.selected {
|
||||||
|
background: rgb(var(--v-theme-lightprimary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab-btn {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-node:hover .kebab-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.kebab-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<v-data-table-server
|
||||||
|
v-model:items-per-page="perPage"
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:sort-by="sortBy"
|
||||||
|
:headers="headers"
|
||||||
|
:items="flashcardDecks"
|
||||||
|
:items-length="totalCount"
|
||||||
|
:loading="isLoading"
|
||||||
|
@click:row="rowClicked"
|
||||||
|
@update:page="updatePage"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="(_, name) in $slots"
|
||||||
|
:key="name"
|
||||||
|
#[name]="slotProps"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
:name="name"
|
||||||
|
v-bind="slotProps"
|
||||||
|
></slot>
|
||||||
|
</template>
|
||||||
|
</v-data-table-server>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export const DEFAULT_HEADERS = [
|
||||||
|
{ title: "Name", key: "name", align: "start" as const, sortable: true },
|
||||||
|
{ title: "Created At", key: "createdAt", align: "start" as const, sortable: true },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
import useVuetifySortByToSafeRouteQuery, {
|
||||||
|
type SortItem,
|
||||||
|
} from "@/use/utils/use-vuetify-sort-by-to-safe-route-query"
|
||||||
|
import useVuetifySortByToSequelizeSafeOrder from "@/use/utils/use-vuetify-sort-by-to-sequelize-safe-order"
|
||||||
|
import useRouteQueryPagination from "@/use/utils/use-route-query-pagination"
|
||||||
|
import useFlashcardDecks, {
|
||||||
|
type FlashcardDeck,
|
||||||
|
type FlashcardDeckFiltersOptions,
|
||||||
|
type FlashcardDeckQueryOptions,
|
||||||
|
type FlashcardDeckWhereOptions,
|
||||||
|
} from "@/use/use-flashcard-decks"
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
headers?: { title: string; key: string }[]
|
||||||
|
filters?: FlashcardDeckFiltersOptions
|
||||||
|
where?: FlashcardDeckWhereOptions
|
||||||
|
sortBy?: SortItem[]
|
||||||
|
routeQuerySuffix?: string
|
||||||
|
waiting?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
headers: () => DEFAULT_HEADERS,
|
||||||
|
filters: () => ({}),
|
||||||
|
where: () => ({}),
|
||||||
|
sortBy: () => [],
|
||||||
|
routeQuerySuffix: "FlashcardDecks",
|
||||||
|
waiting: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { page, perPage } = useRouteQueryPagination({ routeQuerySuffix: props.routeQuerySuffix })
|
||||||
|
const sortBy = useVuetifySortByToSafeRouteQuery(`sortBy${props.routeQuerySuffix}`, props.sortBy)
|
||||||
|
const order = useVuetifySortByToSequelizeSafeOrder(sortBy)
|
||||||
|
|
||||||
|
const queryOptions = computed<FlashcardDeckQueryOptions>(() => ({
|
||||||
|
where: props.where,
|
||||||
|
filters: props.filters,
|
||||||
|
order: order.value,
|
||||||
|
page: page.value,
|
||||||
|
perPage: perPage.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { flashcardDecks, totalCount, isLoading, refresh } = useFlashcardDecks(queryOptions, {
|
||||||
|
skipWatchIf: () => props.waiting,
|
||||||
|
})
|
||||||
|
|
||||||
|
type DeckTableRow = {
|
||||||
|
item: FlashcardDeck
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{ clicked: [deck: FlashcardDeck] }>()
|
||||||
|
|
||||||
|
function rowClicked(_event: unknown, row: DeckTableRow) {
|
||||||
|
emit("clicked", row.item)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePage(newPage: number) {
|
||||||
|
if (isLoading.value || props.waiting) return
|
||||||
|
|
||||||
|
page.value = newPage
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ refresh, totalCount })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="pa-5 flashcard-card">
|
||||||
|
<div
|
||||||
|
class="face-content text-h5 text-center"
|
||||||
|
v-html="renderMarkdown(flashcard.front)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="kebab-wrapper"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<FlashcardKebab
|
||||||
|
:flashcard="flashcard"
|
||||||
|
@updated="$emit('updated', $event)"
|
||||||
|
@deleted="$emit('deleted', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { type Flashcard } from "@/api/flashcards-api"
|
||||||
|
import renderMarkdown from "@/utils/render-markdown"
|
||||||
|
import FlashcardKebab from "@/components/flashcards/FlashcardKebab.vue"
|
||||||
|
|
||||||
|
defineProps<{ flashcard: Flashcard }>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
updated: [flashcard: Flashcard]
|
||||||
|
deleted: [flashcardId: number]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.flashcard-card {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flashcard-card:hover .kebab-wrapper {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.kebab-wrapper {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
v-model="showDialog"
|
||||||
|
width="500"
|
||||||
|
>
|
||||||
|
<v-form
|
||||||
|
ref="formRef"
|
||||||
|
@submit.prevent="validateAndCreate"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Add Flashcard</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-textarea
|
||||||
|
v-model="flashcard.front"
|
||||||
|
label="Front"
|
||||||
|
rows="3"
|
||||||
|
:rules="[required]"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-textarea
|
||||||
|
v-model="flashcard.back"
|
||||||
|
label="Back"
|
||||||
|
rows="3"
|
||||||
|
:rules="[required]"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
text="Cancel"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
:loading="isCreating"
|
||||||
|
text="Create"
|
||||||
|
/>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-form>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { VForm } from "vuetify/components"
|
||||||
|
|
||||||
|
import { required } from "@/utils/validators"
|
||||||
|
import useSnack from "@/use/use-snack"
|
||||||
|
import flashcardsApi, { type Flashcard } from "@/api/flashcards-api"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
flashcardDeckId: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ created: [flashcardId: number] }>()
|
||||||
|
|
||||||
|
const showDialog = ref(false)
|
||||||
|
const isCreating = ref(false)
|
||||||
|
const formRef = ref<InstanceType<typeof VForm> | null>(null)
|
||||||
|
const flashcard = ref<Partial<Flashcard>>({})
|
||||||
|
const snack = useSnack()
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
flashcard.value = {}
|
||||||
|
showDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
showDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateAndCreate() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
|
||||||
|
const { valid } = await formRef.value.validate()
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
isCreating.value = true
|
||||||
|
const { flashcard: newFlashcard } = await flashcardsApi.create({
|
||||||
|
...flashcard.value,
|
||||||
|
flashcardDeckId: props.flashcardDeckId,
|
||||||
|
})
|
||||||
|
emit("created", newFlashcard.id)
|
||||||
|
close()
|
||||||
|
snack.success("Flashcard created")
|
||||||
|
} catch {
|
||||||
|
snack.error("Failed to create flashcard")
|
||||||
|
} finally {
|
||||||
|
isCreating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show, close })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<template>
|
||||||
|
<v-menu>
|
||||||
|
<template #activator="{ props: menuProps }">
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
density="comfortable"
|
||||||
|
size="default"
|
||||||
|
v-bind="menuProps"
|
||||||
|
>
|
||||||
|
<v-icon icon="mdi-dots-vertical" />
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-pencil-outline"
|
||||||
|
title="Edit"
|
||||||
|
@click="openEdit"
|
||||||
|
/>
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-delete-outline"
|
||||||
|
title="Delete"
|
||||||
|
class="text-error"
|
||||||
|
@click="showDelete = true"
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
|
<v-dialog
|
||||||
|
v-model="showEdit"
|
||||||
|
width="500"
|
||||||
|
>
|
||||||
|
<v-form
|
||||||
|
ref="editFormRef"
|
||||||
|
@submit.prevent="updateFlashcard"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Edit Flashcard</v-card-title>
|
||||||
|
<v-card-text class="d-flex flex-column ga-2">
|
||||||
|
<v-textarea
|
||||||
|
v-model="editFront"
|
||||||
|
label="Front"
|
||||||
|
rows="3"
|
||||||
|
auto-grow
|
||||||
|
:rules="[required]"
|
||||||
|
/>
|
||||||
|
<v-textarea
|
||||||
|
v-model="editBack"
|
||||||
|
label="Back"
|
||||||
|
rows="3"
|
||||||
|
auto-grow
|
||||||
|
:rules="[required]"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
text="Cancel"
|
||||||
|
@click="showEdit = false"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
text="Save"
|
||||||
|
/>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-form>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-dialog
|
||||||
|
v-model="showDelete"
|
||||||
|
width="400"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Delete Flashcard</v-card-title>
|
||||||
|
<v-card-text>Are you sure you want to delete this flashcard?</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
text="Cancel"
|
||||||
|
@click="showDelete = false"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
variant="flat"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
text="Delete"
|
||||||
|
@click="deleteFlashcard"
|
||||||
|
/>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { VForm } from "vuetify/components"
|
||||||
|
|
||||||
|
import { required } from "@/utils/validators"
|
||||||
|
import flashcardsApi, { type Flashcard } from "@/api/flashcards-api"
|
||||||
|
import useSnack from "@/use/use-snack"
|
||||||
|
|
||||||
|
const props = defineProps<{ flashcard: Flashcard }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
updated: [flashcard: Flashcard]
|
||||||
|
deleted: [flashcardId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const snack = useSnack()
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
// Edit
|
||||||
|
const showEdit = ref(false)
|
||||||
|
const editFront = ref("")
|
||||||
|
const editBack = ref("")
|
||||||
|
const editFormRef = ref<InstanceType<typeof VForm> | null>(null)
|
||||||
|
|
||||||
|
function openEdit() {
|
||||||
|
editFront.value = props.flashcard.front
|
||||||
|
editBack.value = props.flashcard.back
|
||||||
|
showEdit.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateFlashcard() {
|
||||||
|
if (!editFormRef.value) return
|
||||||
|
const { valid } = await editFormRef.value.validate()
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSubmitting.value = true
|
||||||
|
const { flashcard } = await flashcardsApi.update(props.flashcard.id, {
|
||||||
|
front: editFront.value,
|
||||||
|
back: editBack.value,
|
||||||
|
})
|
||||||
|
emit("updated", flashcard)
|
||||||
|
showEdit.value = false
|
||||||
|
snack.success("Flashcard updated")
|
||||||
|
} catch {
|
||||||
|
snack.error("Failed to update flashcard")
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
const showDelete = ref(false)
|
||||||
|
|
||||||
|
async function deleteFlashcard() {
|
||||||
|
try {
|
||||||
|
isSubmitting.value = true
|
||||||
|
await flashcardsApi.delete(props.flashcard.id)
|
||||||
|
emit("deleted", props.flashcard.id)
|
||||||
|
showDelete.value = false
|
||||||
|
snack.success("Flashcard deleted")
|
||||||
|
} catch {
|
||||||
|
snack.error("Failed to delete flashcard")
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flashcard-scene">
|
||||||
|
<div
|
||||||
|
class="flashcard"
|
||||||
|
:class="{ 'is-flipped': isFlipped }"
|
||||||
|
@click="flip"
|
||||||
|
>
|
||||||
|
<!-- Front -->
|
||||||
|
<v-card
|
||||||
|
class="flashcard-face flashcard-face--front"
|
||||||
|
rounded="xl"
|
||||||
|
elevation="4"
|
||||||
|
>
|
||||||
|
<div class="face-label text-textSecondary text-caption text-uppercase font-weight-bold">
|
||||||
|
Question
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="face-content text-h5 text-center"
|
||||||
|
v-html="renderMarkdown(flashcard.front)"
|
||||||
|
/>
|
||||||
|
<div class="face-hint text-textSecondary text-caption">Click to reveal</div>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- Back -->
|
||||||
|
<v-card
|
||||||
|
class="flashcard-face flashcard-face--back"
|
||||||
|
color="white"
|
||||||
|
rounded="xl"
|
||||||
|
elevation="4"
|
||||||
|
>
|
||||||
|
<div class="face-label text-textSecondary text-caption text-uppercase font-weight-bold">
|
||||||
|
Answer
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="face-content text-h5 text-center"
|
||||||
|
v-html="renderMarkdown(flashcard.back)"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { type Flashcard } from "@/api/flashcards-api"
|
||||||
|
|
||||||
|
import renderMarkdown from "@/utils/render-markdown"
|
||||||
|
|
||||||
|
defineProps<{ flashcard: Flashcard }>()
|
||||||
|
|
||||||
|
const isFlipped = ref(false)
|
||||||
|
|
||||||
|
function flip() {
|
||||||
|
isFlipped.value = !isFlipped.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.flashcard-scene {
|
||||||
|
perspective: 1200px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
height: 380px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flashcard {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flashcard.is-flipped {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flashcard-face {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 48px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flashcard-face--back {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flashcard-face--back :deep(*) {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 28px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-hint {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-actions {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<template>
|
||||||
|
<v-data-table-server
|
||||||
|
v-model:items-per-page="perPage"
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:sort-by="sortBy"
|
||||||
|
:headers="headers"
|
||||||
|
:items="flashcards"
|
||||||
|
:items-length="totalCount"
|
||||||
|
:loading="isLoading"
|
||||||
|
@click:row="rowClicked"
|
||||||
|
@update:page="updatePage"
|
||||||
|
>
|
||||||
|
<template #item.front="{ item }">
|
||||||
|
<span class="text-truncate d-inline-block">{{ item.front }}</span>
|
||||||
|
</template>
|
||||||
|
<template #item.back="{ item }">
|
||||||
|
<span class="text-truncate d-inline-block">{{ item.back }}</span>
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-for="(_, name) in $slots"
|
||||||
|
:key="name"
|
||||||
|
#[name]="slotProps"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
:name="name"
|
||||||
|
v-bind="slotProps"
|
||||||
|
></slot>
|
||||||
|
</template>
|
||||||
|
</v-data-table-server>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export const DEFAULT_HEADERS = [
|
||||||
|
{ title: "Front", key: "front", align: "start" as const, sortable: false },
|
||||||
|
{ title: "Back", key: "back", align: "start" as const, sortable: false },
|
||||||
|
{ title: "Created At", key: "createdAt", align: "start" as const, sortable: true },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
import useVuetifySortByToSafeRouteQuery, {
|
||||||
|
type SortItem,
|
||||||
|
} from "@/use/utils/use-vuetify-sort-by-to-safe-route-query"
|
||||||
|
import useVuetifySortByToSequelizeSafeOrder from "@/use/utils/use-vuetify-sort-by-to-sequelize-safe-order"
|
||||||
|
import useRouteQueryPagination from "@/use/utils/use-route-query-pagination"
|
||||||
|
import useFlashcards, {
|
||||||
|
type Flashcard,
|
||||||
|
type FlashcardFiltersOptions,
|
||||||
|
type FlashcardQueryOptions,
|
||||||
|
type FlashcardWhereOptions,
|
||||||
|
} from "@/use/use-flashcards"
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
headers?: { title: string; key: string }[]
|
||||||
|
filters?: FlashcardFiltersOptions
|
||||||
|
where?: FlashcardWhereOptions
|
||||||
|
sortBy?: SortItem[]
|
||||||
|
routeQuerySuffix?: string
|
||||||
|
waiting?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
headers: () => DEFAULT_HEADERS,
|
||||||
|
filters: () => ({}),
|
||||||
|
where: () => ({}),
|
||||||
|
sortBy: () => [],
|
||||||
|
routeQuerySuffix: "Flashcards",
|
||||||
|
waiting: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { page, perPage } = useRouteQueryPagination({ routeQuerySuffix: props.routeQuerySuffix })
|
||||||
|
const sortBy = useVuetifySortByToSafeRouteQuery(`sortBy${props.routeQuerySuffix}`, props.sortBy)
|
||||||
|
const order = useVuetifySortByToSequelizeSafeOrder(sortBy)
|
||||||
|
|
||||||
|
const queryOptions = computed<FlashcardQueryOptions>(() => ({
|
||||||
|
where: props.where,
|
||||||
|
filters: props.filters,
|
||||||
|
order: order.value,
|
||||||
|
page: page.value,
|
||||||
|
perPage: perPage.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { flashcards, totalCount, isLoading, refresh } = useFlashcards(queryOptions, {
|
||||||
|
skipWatchIf: () => props.waiting,
|
||||||
|
})
|
||||||
|
|
||||||
|
type FlashcardTableRow = {
|
||||||
|
item: Flashcard
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{ clicked: [flashcard: Flashcard] }>()
|
||||||
|
|
||||||
|
function rowClicked(_event: unknown, row: FlashcardTableRow) {
|
||||||
|
emit("clicked", row.item)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePage(newPage: number) {
|
||||||
|
if (isLoading.value || props.waiting) return
|
||||||
|
|
||||||
|
page.value = newPage
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ refresh, totalCount })
|
||||||
|
</script>
|
||||||
@@ -1,18 +1,53 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-app-bar
|
<div v-if="mobile">
|
||||||
id="top"
|
<v-app-bar
|
||||||
elevation="6"
|
id="top"
|
||||||
height="60"
|
elevation="6"
|
||||||
class="main-head pl-2"
|
height="60"
|
||||||
>
|
extension-height="48"
|
||||||
<div class="mr-3">
|
class="main-head pl-2"
|
||||||
<AppLogo />
|
>
|
||||||
</div>
|
<div class="mr-3">
|
||||||
|
<!-- <AppLogo /> -->
|
||||||
|
<PublicAppLogo
|
||||||
|
to="/dashboard"
|
||||||
|
:animated="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
|
|
||||||
<!-- TODO Add notifications -->
|
<ProfileMenu />
|
||||||
<!-- <v-btn
|
</v-app-bar>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<v-app-bar
|
||||||
|
id="top"
|
||||||
|
elevation="6"
|
||||||
|
height="60"
|
||||||
|
class="main-head pl-2"
|
||||||
|
>
|
||||||
|
<div class="mr-3">
|
||||||
|
<!-- <AppLogo /> -->
|
||||||
|
<PublicAppLogo
|
||||||
|
to="/"
|
||||||
|
:animated="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-divider vertical />
|
||||||
|
<ExactingBreadcrumbs
|
||||||
|
class="appbar-title"
|
||||||
|
density="compact"
|
||||||
|
:items="breadcrumbs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-spacer />
|
||||||
|
|
||||||
|
<!-- TODO Add notifications -->
|
||||||
|
<!-- <v-btn
|
||||||
icon
|
icon
|
||||||
class="mr-1"
|
class="mr-1"
|
||||||
>
|
>
|
||||||
@@ -28,11 +63,38 @@
|
|||||||
</v-badge>
|
</v-badge>
|
||||||
</v-btn> -->
|
</v-btn> -->
|
||||||
|
|
||||||
<ProfileMenu />
|
<ProfileMenu />
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppLogo from "@/components/common/AppLogo.vue"
|
import { useDisplay } from "vuetify"
|
||||||
|
|
||||||
|
import useBreadcrumbs from "@/use/use-breadcrumbs"
|
||||||
|
|
||||||
|
import PublicAppLogo from "@/components/common/PublicAppLogo.vue"
|
||||||
import ProfileMenu from "@/components/layout/ProfileMenu.vue"
|
import ProfileMenu from "@/components/layout/ProfileMenu.vue"
|
||||||
|
import ExactingBreadcrumbs from "@/components/layout/ExactingBreadcrumbs.vue"
|
||||||
|
|
||||||
|
const { mobile } = useDisplay()
|
||||||
|
const { breadcrumbs } = useBreadcrumbs(undefined, undefined, {
|
||||||
|
baseCrumb: {
|
||||||
|
title: "Dashboard",
|
||||||
|
to: {
|
||||||
|
name: "DashboardPage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.appbar-title {
|
||||||
|
margin-left: 12px;
|
||||||
|
padding: 0 0 0 12px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -106,9 +106,9 @@
|
|||||||
<v-btn
|
<v-btn
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
block
|
block
|
||||||
|
text="Logout"
|
||||||
@click="signOut"
|
@click="signOut"
|
||||||
>Logout</v-btn
|
/>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="mobile">
|
||||||
|
<v-app-bar
|
||||||
|
id="top"
|
||||||
|
elevation="6"
|
||||||
|
height="60"
|
||||||
|
class="main-head pl-2"
|
||||||
|
>
|
||||||
|
<div class="mr-3">
|
||||||
|
<PublicAppLogo to="/" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-spacer />
|
||||||
|
|
||||||
|
<ProfileMenu v-if="isAuthenticated" />
|
||||||
|
<v-btn
|
||||||
|
v-else
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
text="Sign in"
|
||||||
|
:to="{ name: 'SignInPage' }"
|
||||||
|
/>
|
||||||
|
</v-app-bar>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<v-app-bar
|
||||||
|
id="top"
|
||||||
|
elevation="6"
|
||||||
|
height="60"
|
||||||
|
class="main-head pl-2"
|
||||||
|
>
|
||||||
|
<div class="mr-3">
|
||||||
|
<PublicAppLogo to="/" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-spacer />
|
||||||
|
|
||||||
|
<ProfileMenu v-if="isAuthenticated" />
|
||||||
|
<v-btn
|
||||||
|
v-else
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
text="Sign in"
|
||||||
|
:to="{ name: 'SignInPage' }"
|
||||||
|
/>
|
||||||
|
</v-app-bar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useDisplay } from "vuetify"
|
||||||
|
import { useAuth0 } from "@auth0/auth0-vue"
|
||||||
|
|
||||||
|
import ProfileMenu from "@/components/layout/ProfileMenu.vue"
|
||||||
|
import PublicAppLogo from "@/components/common/PublicAppLogo.vue"
|
||||||
|
|
||||||
|
const { mobile } = useDisplay()
|
||||||
|
const { isAuthenticated } = useAuth0()
|
||||||
|
</script>
|
||||||
+2
-2
@@ -6,8 +6,8 @@ const prodConfig = {
|
|||||||
domain: "https://dev-7mdjzcgwirhocfwm.ca.auth0.com",
|
domain: "https://dev-7mdjzcgwirhocfwm.ca.auth0.com",
|
||||||
clientId: "TRlKzdNBynpo9tU1RSmnF0p8d3IEam4J",
|
clientId: "TRlKzdNBynpo9tU1RSmnF0p8d3IEam4J",
|
||||||
audience: "alphane-api",
|
audience: "alphane-api",
|
||||||
apiBaseUrl: "",
|
apiBaseUrl: "https://calebburke.dev",
|
||||||
webSocketBaseUrl: "",
|
webSocketBaseUrl: "wss://calebburke.dev",
|
||||||
applicationName: "calebburkedev",
|
applicationName: "calebburkedev",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<template>
|
||||||
|
<v-app>
|
||||||
|
<PublicAppBar class="mb-2" />
|
||||||
|
|
||||||
|
<v-main>
|
||||||
|
<v-container
|
||||||
|
fluid
|
||||||
|
:class="mobile ? 'pa-2' : 'pa-4'"
|
||||||
|
>
|
||||||
|
<div class="public-content mt-4">
|
||||||
|
<v-row class="public-row">
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="3"
|
||||||
|
class="sidebar-col"
|
||||||
|
>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<nav
|
||||||
|
class="sidebar-nav mb-6"
|
||||||
|
aria-label="Primary"
|
||||||
|
>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'HomePage' }"
|
||||||
|
class="sidebar-nav-link"
|
||||||
|
>
|
||||||
|
<span>Home</span
|
||||||
|
><span :class="{ 'text-warning': route.name === 'HomePage' }">~/</span>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'BlogPage' }"
|
||||||
|
class="sidebar-nav-link"
|
||||||
|
>
|
||||||
|
<span>Blog</span
|
||||||
|
><span :class="{ 'text-warning': route.name === 'BlogPage' }">~/blog</span>
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="9"
|
||||||
|
>
|
||||||
|
<router-view />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</v-container>
|
||||||
|
</v-main>
|
||||||
|
|
||||||
|
<footer class="public-footer">
|
||||||
|
<span>© {{ currentYear }} Caleb Burke</span>
|
||||||
|
|
||||||
|
<div class="social-links">
|
||||||
|
<a
|
||||||
|
href="https://github.com/burkkyy"
|
||||||
|
class="social-link"
|
||||||
|
title="GitHub"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<v-icon size="24">mdi-github</v-icon>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://gitea.burke.host/burkkyy"
|
||||||
|
class="social-link"
|
||||||
|
title="Gitea"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="social-svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<title>Gitea</title>
|
||||||
|
<path
|
||||||
|
d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://discord.com/users/burkkyy"
|
||||||
|
class="social-link"
|
||||||
|
title="Discord"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="social-svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<title>Discord</title>
|
||||||
|
<path
|
||||||
|
d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://www.linkedin.com/in/calebburke91/"
|
||||||
|
class="social-link"
|
||||||
|
title="LinkedIn"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<v-icon size="24">mdi-linkedin</v-icon>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="mailto:calebburke91@gmail.com"
|
||||||
|
class="social-link"
|
||||||
|
title="Gmail"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<v-icon size="24">mdi-gmail</v-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useDisplay } from "vuetify"
|
||||||
|
import { useRoute } from "vue-router"
|
||||||
|
import { DateTime } from "luxon"
|
||||||
|
|
||||||
|
import PublicAppBar from "@/components/layout/PublicAppBar.vue"
|
||||||
|
|
||||||
|
const { mobile } = useDisplay()
|
||||||
|
const route = useRoute()
|
||||||
|
const currentYear = DateTime.local().year
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.public-content {
|
||||||
|
max-width: 1300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-link {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 10px;
|
||||||
|
margin: 0 -10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: ui-monospace, Menlo, Monaco, Consolas, "Ubuntu Mono", monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgb(var(--v-theme-textSecondary));
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-link:hover,
|
||||||
|
.sidebar-nav-link.router-link-active {
|
||||||
|
background-color: rgb(var(--v-theme-hoverColor));
|
||||||
|
color: rgb(var(--v-theme-textPrimary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: rgb(var(--v-theme-textSecondary));
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-link:hover {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid rgb(var(--v-theme-borderColor));
|
||||||
|
font-family: ui-monospace, Menlo, Monaco, Consolas, "Ubuntu Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgb(var(--v-theme-textSecondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-footer {
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="logo">
|
|
||||||
<RouterLink
|
|
||||||
to="/dashboard"
|
|
||||||
class="d-flex"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
class="ml-0 mt-1"
|
|
||||||
style="height: 36px; transform: rotate(-12deg)"
|
|
||||||
:src="AppLogoSmall"
|
|
||||||
/>
|
|
||||||
<div v-if="sidebarMini || mdAndDown"></div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="d-flex"
|
|
||||||
style="width: 200px"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mt-1 ml-3"
|
|
||||||
style="font-size: 26px; color: #505682"
|
|
||||||
>
|
|
||||||
CALEB BURKE DEV
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import AppLogoSmall from "@/assets/app_logo_small.png"
|
|
||||||
|
|
||||||
import { useDisplay } from "vuetify"
|
|
||||||
import useInterface from "@/use/use-interface"
|
|
||||||
import { watch } from "vue"
|
|
||||||
|
|
||||||
const { sidebarMini, setSidebarMini } = useInterface()
|
|
||||||
const { mdAndDown } = useDisplay()
|
|
||||||
|
|
||||||
watch(mdAndDown, (newVal) => {
|
|
||||||
if (newVal === true) setSidebarMini(false)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.logo a {
|
|
||||||
text-decoration: none !important;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<v-row class="home-row ml-5">
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="8"
|
||||||
|
>
|
||||||
|
<main>
|
||||||
|
<section class="posts-section">
|
||||||
|
<PublicBlogPostsList />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import useBreadcrumbs from "@/use/use-breadcrumbs"
|
||||||
|
|
||||||
|
import PublicBlogPostsList from "@/components/blog-posts/PublicBlogPostsList.vue"
|
||||||
|
|
||||||
|
useBreadcrumbs("Blog", [
|
||||||
|
{
|
||||||
|
title: "Blog",
|
||||||
|
to: {
|
||||||
|
name: "BlogPage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.section-eyebrow {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,15 +4,70 @@
|
|||||||
You are a system admin
|
You are a system admin
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<v-row class="mt-1">
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
lg="4"
|
||||||
|
>
|
||||||
|
<DashboardCard
|
||||||
|
:to="{ name: 'FlashcardsPage' }"
|
||||||
|
title="Flashcards"
|
||||||
|
subtitle="Study and manage your flashcard decks"
|
||||||
|
:count="flashcardDecksCount"
|
||||||
|
count-label="Total Decks"
|
||||||
|
color="success"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<v-icon
|
||||||
|
size="32"
|
||||||
|
color="white"
|
||||||
|
>
|
||||||
|
mdi-cards-outline
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
</DashboardCard>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
lg="4"
|
||||||
|
>
|
||||||
|
<DashboardCard
|
||||||
|
:to="{ name: 'BlogPage' }"
|
||||||
|
title="Blog Posts"
|
||||||
|
subtitle="Read and manage blog posts"
|
||||||
|
:count="blogPostsCount"
|
||||||
|
count-label="Total Posts"
|
||||||
|
color="info"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<v-icon
|
||||||
|
size="32"
|
||||||
|
color="white"
|
||||||
|
>
|
||||||
|
mdi-post-outline
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
</DashboardCard>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import useBreadcrumbs from "@/use/use-breadcrumbs"
|
import useBreadcrumbs from "@/use/use-breadcrumbs"
|
||||||
import useCurrentUser from "@/use/use-current-user"
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
import useFlashcardDecks from "@/use/use-flashcard-decks"
|
||||||
|
import useBlogPosts from "@/use/use-blog-posts"
|
||||||
|
|
||||||
import AppCard from "@/components/common/AppCard.vue"
|
import AppCard from "@/components/common/AppCard.vue"
|
||||||
|
import DashboardCard from "@/components/common/DashboardCard.vue"
|
||||||
|
|
||||||
const { isSystemAdmin } = useCurrentUser<true>()
|
const { isSystemAdmin } = useCurrentUser<true>()
|
||||||
|
|
||||||
useBreadcrumbs()
|
const { totalCount: flashcardDecksCount } = useFlashcardDecks()
|
||||||
|
const { totalCount: blogPostsCount } = useBlogPosts()
|
||||||
|
|
||||||
|
useBreadcrumbs("Dashboard", [])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<template>
|
||||||
|
<v-row class="fill-height">
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="3"
|
||||||
|
>
|
||||||
|
<v-card
|
||||||
|
height="100%"
|
||||||
|
class="pa-2"
|
||||||
|
>
|
||||||
|
<FlashcardDeckTree
|
||||||
|
ref="deckTree"
|
||||||
|
@select="onDeckSelected"
|
||||||
|
@updated="onDeckUpdated"
|
||||||
|
@deleted="onDeckDeleted"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="9"
|
||||||
|
>
|
||||||
|
<template v-if="selectedDeck">
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="3"
|
||||||
|
>
|
||||||
|
<span class="text-h3">{{ selectedDeck.name }}</span>
|
||||||
|
<span class="ml-2">({{ totalCount }})</span>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
/>
|
||||||
|
<v-col
|
||||||
|
class="d-flex align-center"
|
||||||
|
cols="12"
|
||||||
|
md="2"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
text="Add Flashcard"
|
||||||
|
@click="openFlashcardCreateDialog"
|
||||||
|
/>
|
||||||
|
<FlashcardDeckStartReviewBtn :flashcard-deck-id="selectedDeck.id" />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-divider class="my-5" />
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
v-for="flashcard in flashcards"
|
||||||
|
:key="flashcard.id"
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
lg="4"
|
||||||
|
>
|
||||||
|
<FlashcardCard
|
||||||
|
:flashcard="flashcard"
|
||||||
|
@updated="refreshFlashcards"
|
||||||
|
@deleted="refreshFlashcards"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<EnhancedPagination
|
||||||
|
v-model="page"
|
||||||
|
v-model:per-page="perPage"
|
||||||
|
:total-count="totalCount"
|
||||||
|
class="mt-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FlashcardCreateDialog
|
||||||
|
v-if="selectedDeck"
|
||||||
|
ref="flashcardCreateDialog"
|
||||||
|
:flashcard-deck-id="selectedDeck.id"
|
||||||
|
@created="refreshFlashcards"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="d-flex align-center justify-center h-100 text-medium-emphasis"
|
||||||
|
>
|
||||||
|
Select a deck to view its flashcards
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue"
|
||||||
|
|
||||||
|
import useBreadcrumbs from "@/use/use-breadcrumbs"
|
||||||
|
import useFlashcards from "@/use/use-flashcards"
|
||||||
|
import useRouteQueryPagination from "@/use/utils/use-route-query-pagination"
|
||||||
|
|
||||||
|
import flashcardDecksApi from "@/api/flashcard-decks-api"
|
||||||
|
import FlashcardDeckTree, {
|
||||||
|
type DeckNode,
|
||||||
|
} from "@/components/flashcard-decks/FlashcardDeckTree.vue"
|
||||||
|
import FlashcardCard from "@/components/flashcards/FlashcardCard.vue"
|
||||||
|
import FlashcardCreateDialog from "@/components/flashcards/FlashcardCreateDialog.vue"
|
||||||
|
import FlashcardDeckStartReviewBtn from "@/components/flashcard-decks/FlashcardDeckStartReviewBtn.vue"
|
||||||
|
import EnhancedPagination from "@/components/common/EnhancedPagination.vue"
|
||||||
|
|
||||||
|
const deckTree = ref<InstanceType<typeof FlashcardDeckTree> | null>(null)
|
||||||
|
const selectedDeck = ref<DeckNode | null>(null)
|
||||||
|
const flashcardCreateDialog = ref<InstanceType<typeof FlashcardCreateDialog> | null>(null)
|
||||||
|
|
||||||
|
const { page, perPage } = useRouteQueryPagination({ perPage: 4 })
|
||||||
|
|
||||||
|
const flashcardsQueryOptions = computed(() => ({
|
||||||
|
where: { flashcardDeckId: selectedDeck.value?.id },
|
||||||
|
page: page.value,
|
||||||
|
perPage: perPage.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const {
|
||||||
|
flashcards,
|
||||||
|
totalCount,
|
||||||
|
refresh: refreshFlashcards,
|
||||||
|
} = useFlashcards(flashcardsQueryOptions, {
|
||||||
|
skipWatchIf: () => selectedDeck.value === null,
|
||||||
|
})
|
||||||
|
|
||||||
|
function onDeckSelected(deck: DeckNode) {
|
||||||
|
selectedDeck.value = deck
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeckDeleted(flashcardDeckId: number) {
|
||||||
|
if (selectedDeck.value?.id !== flashcardDeckId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedDeck.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeckUpdated(flashcardDeckId: number) {
|
||||||
|
if (selectedDeck.value?.id !== flashcardDeckId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { flashcardDeck } = await flashcardDecksApi.get(flashcardDeckId)
|
||||||
|
selectedDeck.value = { ...selectedDeck.value, ...flashcardDeck }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFlashcardCreateDialog() {
|
||||||
|
flashcardCreateDialog.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
useBreadcrumbs("Flashcards", [
|
||||||
|
{
|
||||||
|
title: "Flashcards",
|
||||||
|
to: {
|
||||||
|
name: "FlashcardsPage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
<template>
|
||||||
|
<v-row class="home-row">
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="8"
|
||||||
|
>
|
||||||
|
<main>
|
||||||
|
<div class="terminal mb-10">
|
||||||
|
<div class="terminal-bar">
|
||||||
|
<span class="terminal-dot terminal-dot-red"></span>
|
||||||
|
<span class="terminal-dot terminal-dot-yellow"></span>
|
||||||
|
<span class="terminal-dot terminal-dot-green"></span>
|
||||||
|
<span class="terminal-title">calebburke.dev</span>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-prompt">
|
||||||
|
<span class="term-green">caleb</span>
|
||||||
|
<span class="term-cyan">@</span><span class="term-green">burke</span>
|
||||||
|
<span class="term-white"> ~</span>
|
||||||
|
<span class="term-cyan">$</span>
|
||||||
|
<span class="terminal-cmd"> cat about.txt</span>
|
||||||
|
<span class="terminal-out"> Full Stack Developer </span>
|
||||||
|
|
||||||
|
<span class="term-green">caleb</span>
|
||||||
|
<span class="term-cyan">@</span>
|
||||||
|
<span class="term-green">burke</span>
|
||||||
|
<span class="term-white"> ~</span>
|
||||||
|
<span class="term-cyan">$ </span>
|
||||||
|
<span class="terminal-cmd">{{ typedText }}</span>
|
||||||
|
<span class="term-cursor">█</span>
|
||||||
|
<!-- <span class="term-green">caleb</span><span class="term-cyan">@</span
|
||||||
|
><span class="term-green">burke</span><span class="term-white">:~$</span>
|
||||||
|
<span class="terminal-cmd"> ls ~/posts | head -3</span>
|
||||||
|
<span
|
||||||
|
v-if="isLoading"
|
||||||
|
class="terminal-out terminal-out-dim"
|
||||||
|
>loading…</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-else-if="recentBlogPosts.length === 0"
|
||||||
|
class="terminal-out terminal-out-dim"
|
||||||
|
>(no posts published yet)</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="terminal-out terminal-out-dim"
|
||||||
|
>{{ recentBlogPosts.map((blogPost) => blogPost.slug).join(" ") }}</span
|
||||||
|
> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="posts-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<span class="section-eyebrow font-mono mb-4 text-warning">~/blog</span>
|
||||||
|
<h2 class="text-h3 font-serif">Recent posts</h2>
|
||||||
|
</div>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'BlogPage' }"
|
||||||
|
class="section-view-all"
|
||||||
|
>
|
||||||
|
view all
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoading">
|
||||||
|
<v-skeleton-loader
|
||||||
|
v-for="index in 3"
|
||||||
|
:key="index"
|
||||||
|
type="list-item-two-line"
|
||||||
|
class="mb-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-else-if="totalCount === 0"
|
||||||
|
class="text-medium-emphasis"
|
||||||
|
>
|
||||||
|
Nothing published yet — check back soon.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="(blogPost, index) of recentBlogPosts"
|
||||||
|
v-else
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<BlogPostListItemCard
|
||||||
|
:blog-post="blogPost"
|
||||||
|
class="mb-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="4"
|
||||||
|
>
|
||||||
|
<aside class="rail">
|
||||||
|
<!-- <div
|
||||||
|
v-if="allTags.length"
|
||||||
|
class="rail-block"
|
||||||
|
>
|
||||||
|
<span class="rail-eyebrow">Tags</span>
|
||||||
|
<div class="tag-cloud">
|
||||||
|
<span
|
||||||
|
v-for="tag in allTags"
|
||||||
|
:key="tag"
|
||||||
|
class="tag-chip"
|
||||||
|
>{{ tag }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<div class="rail-block">
|
||||||
|
<v-card
|
||||||
|
variant="outlined"
|
||||||
|
class="now-box pa-4 mb-4"
|
||||||
|
>
|
||||||
|
<div class="d-flex justify-space-between ga-2">
|
||||||
|
<span class="text-medium-emphasis">release</span>
|
||||||
|
<span>{{ status.releaseTag || "dev" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-space-between ga-2 my-2">
|
||||||
|
<span class="text-medium-emphasis">commit</span>
|
||||||
|
<span>{{ shortCommitHash }}</span>
|
||||||
|
</div>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'StatusPage' }"
|
||||||
|
class="text-primary text-decoration-none mt-2 d-block"
|
||||||
|
>
|
||||||
|
view status
|
||||||
|
</RouterLink>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isAuthenticated"
|
||||||
|
class="rail-block"
|
||||||
|
>
|
||||||
|
<v-card
|
||||||
|
variant="tonal"
|
||||||
|
color="primary"
|
||||||
|
class="pa-4"
|
||||||
|
>
|
||||||
|
<p class="text-body-2 mb-3">Welcome back — jump into your dashboard.</p>
|
||||||
|
<v-btn
|
||||||
|
:to="isAuthenticated ? { name: 'DashboardPage' } : { name: 'SignInPage' }"
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
size="small"
|
||||||
|
text="Go to dashboard"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, reactive, ref } from "vue"
|
||||||
|
import { useAuth0 } from "@auth0/auth0-vue"
|
||||||
|
|
||||||
|
import http from "@/api/http-client"
|
||||||
|
|
||||||
|
import usePublicBlogPosts from "@/use/use-public-blog-posts"
|
||||||
|
|
||||||
|
import BlogPostListItemCard from "@/components/blog-posts/BlogPostListItemCard.vue"
|
||||||
|
|
||||||
|
const { isAuthenticated } = useAuth0()
|
||||||
|
|
||||||
|
const { publicBlogPosts, totalCount, isLoading } = usePublicBlogPosts()
|
||||||
|
|
||||||
|
const recentBlogPosts = computed(() => publicBlogPosts.value.slice(0, 4))
|
||||||
|
// const allTags = computed(() => [
|
||||||
|
// ...new Set(publicBlogPosts.value.flatMap((blogPost) => blogPost.tags)),
|
||||||
|
// ])
|
||||||
|
|
||||||
|
const status = reactive({
|
||||||
|
releaseTag: "",
|
||||||
|
gitCommitHash: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
const shortCommitHash = computed(() => status.gitCommitHash.slice(0, 7) || "local")
|
||||||
|
|
||||||
|
const TYPED_PHRASES = [
|
||||||
|
" Hello!",
|
||||||
|
" sudo rm -rf --no-preserve-root /",
|
||||||
|
" :(){ :|:& };:",
|
||||||
|
" Cheers!",
|
||||||
|
" привет",
|
||||||
|
" クリア",
|
||||||
|
" Εβίβα!",
|
||||||
|
" 干杯",
|
||||||
|
" Будьмо!",
|
||||||
|
" Na zdraví!",
|
||||||
|
" 乾杯!",
|
||||||
|
]
|
||||||
|
const TYPE_DELAY_MS = 150
|
||||||
|
const DELETE_DELAY_MS = 100
|
||||||
|
const PAUSE_AFTER_TYPED_MS = 1500
|
||||||
|
const PAUSE_AFTER_DELETED_MS = 5000
|
||||||
|
const INITIAL_DELAY_MS = 800
|
||||||
|
|
||||||
|
const typedText = ref("")
|
||||||
|
let currentPhrase = ""
|
||||||
|
let typingTimeoutId: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
function pickRandomPhrase() {
|
||||||
|
return TYPED_PHRASES[Math.floor(Math.random() * TYPED_PHRASES.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeCharacter(index: number) {
|
||||||
|
typedText.value = currentPhrase.slice(0, index)
|
||||||
|
|
||||||
|
if (index < currentPhrase.length) {
|
||||||
|
typingTimeoutId = setTimeout(() => typeCharacter(index + 1), TYPE_DELAY_MS)
|
||||||
|
} else {
|
||||||
|
typingTimeoutId = setTimeout(() => deleteCharacter(index), PAUSE_AFTER_TYPED_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCharacter(index: number) {
|
||||||
|
typedText.value = currentPhrase.slice(0, index)
|
||||||
|
|
||||||
|
if (index > 0) {
|
||||||
|
typingTimeoutId = setTimeout(() => deleteCharacter(index - 1), DELETE_DELAY_MS)
|
||||||
|
} else {
|
||||||
|
typingTimeoutId = setTimeout(() => {
|
||||||
|
currentPhrase = pickRandomPhrase()
|
||||||
|
typeCharacter(0)
|
||||||
|
}, PAUSE_AFTER_DELETED_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
currentPhrase = pickRandomPhrase()
|
||||||
|
typingTimeoutId = setTimeout(() => typeCharacter(0), INITIAL_DELAY_MS)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await http.get("/_status")
|
||||||
|
status.releaseTag = data.RELEASE_TAG
|
||||||
|
status.gitCommitHash = data.GIT_COMMIT_HASH
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch status:", error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearTimeout(typingTimeoutId)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rail {
|
||||||
|
position: sticky;
|
||||||
|
top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terminal hero */
|
||||||
|
.terminal {
|
||||||
|
background-color: rgb(var(--v-theme-surface));
|
||||||
|
border: 1px solid rgb(var(--v-theme-borderColor));
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background-color: rgb(var(--v-theme-containerBg));
|
||||||
|
border-bottom: 1px solid rgb(var(--v-theme-borderColor));
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-dot-red {
|
||||||
|
background-color: rgb(var(--v-theme-error));
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-dot-yellow {
|
||||||
|
background-color: rgb(var(--v-theme-warning));
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-dot-green {
|
||||||
|
background-color: rgb(var(--v-theme-success));
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-title {
|
||||||
|
margin: 0 auto;
|
||||||
|
transform: translateX(-16px);
|
||||||
|
font-family: ui-monospace, Menlo, Monaco, Consolas, "Ubuntu Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgb(var(--v-theme-textSecondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-prompt {
|
||||||
|
display: block;
|
||||||
|
padding: 20px 22px 24px;
|
||||||
|
font-family: ui-monospace, Menlo, Monaco, Consolas, "Ubuntu Mono", monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-cmd {
|
||||||
|
color: rgb(var(--v-theme-textPrimary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-out {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: rgb(var(--v-theme-textSecondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-out-dim {
|
||||||
|
color: rgb(var(--v-theme-textSecondary) / 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes terminal-blink {
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section headers */
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-eyebrow {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-view-all {
|
||||||
|
font-family: ui-monospace, Menlo, Monaco, Consolas, "Ubuntu Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgb(var(--v-theme-textSecondary));
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid rgb(var(--v-theme-borderColor));
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-view-all:hover {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
font-family: ui-monospace, Menlo, Monaco, Consolas, "Ubuntu Mono", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background-color: rgb(var(--v-theme-surface));
|
||||||
|
border: 1px solid rgb(var(--v-theme-borderColor));
|
||||||
|
color: rgb(var(--v-theme-textSecondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-cloud {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right rail */
|
||||||
|
.rail-eyebrow {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-family: ui-monospace, Menlo, Monaco, Consolas, "Ubuntu Mono", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: rgb(var(--v-theme-textSecondary) / 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-box {
|
||||||
|
font-family: ui-monospace, Menlo, Monaco, Consolas, "Ubuntu Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.rail {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -16,17 +16,7 @@
|
|||||||
class="px-8"
|
class="px-8"
|
||||||
style="max-width: 500px"
|
style="max-width: 500px"
|
||||||
>
|
>
|
||||||
<!-- <img
|
<PublicAppLogo />
|
||||||
class="d-inline-block d-lg-none"
|
|
||||||
src="@/assets/app_logo_small.png"
|
|
||||||
style="height: 66px; transform: rotate(-12deg)"
|
|
||||||
/> -->
|
|
||||||
<h2
|
|
||||||
class="text-h1 textPrimary font-weight-semibold mb-0"
|
|
||||||
style="font-size: 2.5rem !important"
|
|
||||||
>
|
|
||||||
{{ APPLICATION_NAME }}
|
|
||||||
</h2>
|
|
||||||
<h6 class="text-h6 text-medium-emphasis d-flex align-center mt-6 font-weight-medium">
|
<h6 class="text-h6 text-medium-emphasis d-flex align-center mt-6 font-weight-medium">
|
||||||
<v-btn
|
<v-btn
|
||||||
block
|
block
|
||||||
@@ -37,10 +27,6 @@
|
|||||||
</h6>
|
</h6>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
<div class="my-6 text-medium-emphasis">
|
|
||||||
If you don't have an account, create one to get started using ROTYR. It's free to sign
|
|
||||||
up and only takes a few seconds.
|
|
||||||
</div>
|
|
||||||
<v-btn
|
<v-btn
|
||||||
block
|
block
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -63,20 +49,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
<!-- <v-col
|
|
||||||
cols="12"
|
|
||||||
lg="7"
|
|
||||||
xl="8"
|
|
||||||
class="d-none d-lg-flex align-center justify-center authentication position-relative"
|
|
||||||
>
|
|
||||||
<div class="text-center">
|
|
||||||
<img
|
|
||||||
src="@/assets/app_logo_splash.png"
|
|
||||||
class="position-relative"
|
|
||||||
style="opacity: 0.15; width: 80%"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</v-col> -->
|
|
||||||
</v-row>
|
</v-row>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -85,7 +57,7 @@
|
|||||||
import { onMounted } from "vue"
|
import { onMounted } from "vue"
|
||||||
import { useAuth0 } from "@auth0/auth0-vue"
|
import { useAuth0 } from "@auth0/auth0-vue"
|
||||||
|
|
||||||
import { APPLICATION_NAME } from "@/config"
|
import PublicAppLogo from "@/components/common/PublicAppLogo.vue"
|
||||||
import useCurrentUser from "@/use/use-current-user"
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
|
||||||
const { reset: resetCurrentUser } = useCurrentUser()
|
const { reset: resetCurrentUser } = useCurrentUser()
|
||||||
|
|||||||
@@ -1,105 +1,53 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-row>
|
<v-row>
|
||||||
<!-- Users Card -->
|
|
||||||
<v-col
|
<v-col
|
||||||
v-if="isSystemAdmin"
|
v-if="isSystemAdmin"
|
||||||
cols="12"
|
cols="12"
|
||||||
md="6"
|
md="6"
|
||||||
lg="4"
|
lg="4"
|
||||||
>
|
>
|
||||||
<v-card
|
<DashboardCard
|
||||||
elevation="10"
|
|
||||||
:to="{ name: 'administration/UsersPage' }"
|
:to="{ name: 'administration/UsersPage' }"
|
||||||
hover
|
title="Users"
|
||||||
|
subtitle="Manage users and permissions"
|
||||||
|
:count="usersCount"
|
||||||
|
count-label="Total Users"
|
||||||
|
color="success"
|
||||||
>
|
>
|
||||||
<v-card-item>
|
<template #icon>
|
||||||
<div class="d-flex align-center">
|
<v-icon
|
||||||
<v-avatar
|
size="32"
|
||||||
color="success"
|
color="white"
|
||||||
size="56"
|
>
|
||||||
class="mr-4"
|
mdi-account-group
|
||||||
>
|
</v-icon>
|
||||||
<v-icon
|
</template>
|
||||||
size="32"
|
</DashboardCard>
|
||||||
color="white"
|
|
||||||
>
|
|
||||||
mdi-account-group
|
|
||||||
</v-icon>
|
|
||||||
</v-avatar>
|
|
||||||
<div>
|
|
||||||
<v-card-title class="text-h5">Users</v-card-title>
|
|
||||||
<v-card-subtitle>Manage users and permissions</v-card-subtitle>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</v-card-item>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<v-card-text>
|
|
||||||
<div class="d-flex align-center justify-space-between">
|
|
||||||
<span class="text-h3 font-weight-bold">{{ usersCount }}</span>
|
|
||||||
<v-icon
|
|
||||||
size="large"
|
|
||||||
color="success"
|
|
||||||
>
|
|
||||||
mdi-chevron-right
|
|
||||||
</v-icon>
|
|
||||||
</div>
|
|
||||||
<div class="text-caption text-medium-emphasis mt-1">Total Users</div>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<!-- Settings Card -->
|
|
||||||
<v-col
|
<v-col
|
||||||
v-if="isSystemAdmin"
|
v-if="isSystemAdmin"
|
||||||
cols="12"
|
cols="12"
|
||||||
md="6"
|
md="6"
|
||||||
lg="4"
|
lg="4"
|
||||||
>
|
>
|
||||||
<v-card
|
<DashboardCard
|
||||||
elevation="10"
|
|
||||||
:to="{ name: 'administration/SettingsPage' }"
|
:to="{ name: 'administration/SettingsPage' }"
|
||||||
hover
|
title="Settings"
|
||||||
|
subtitle="System configuration"
|
||||||
|
count-label="Configure System"
|
||||||
|
color="info"
|
||||||
>
|
>
|
||||||
<v-card-item>
|
<template #icon>
|
||||||
<div class="d-flex align-center">
|
<v-icon
|
||||||
<v-avatar
|
size="32"
|
||||||
color="info"
|
color="white"
|
||||||
size="56"
|
>
|
||||||
class="mr-4"
|
mdi-cog
|
||||||
>
|
</v-icon>
|
||||||
<v-icon
|
</template>
|
||||||
size="32"
|
</DashboardCard>
|
||||||
color="white"
|
|
||||||
>
|
|
||||||
mdi-cog
|
|
||||||
</v-icon>
|
|
||||||
</v-avatar>
|
|
||||||
<div>
|
|
||||||
<v-card-title class="text-h5">Settings</v-card-title>
|
|
||||||
<v-card-subtitle>System configuration</v-card-subtitle>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</v-card-item>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<v-card-text>
|
|
||||||
<div class="d-flex align-center justify-space-between">
|
|
||||||
<span class="text-h3 font-weight-bold"> </span>
|
|
||||||
|
|
||||||
<v-icon
|
|
||||||
size="large"
|
|
||||||
color="info"
|
|
||||||
>
|
|
||||||
mdi-chevron-right
|
|
||||||
</v-icon>
|
|
||||||
</div>
|
|
||||||
<div class="text-caption text-medium-emphasis mt-1">Configure System</div>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,6 +58,8 @@ import useBreadcrumbs from "@/use/use-breadcrumbs"
|
|||||||
import useUsers from "@/use/use-users"
|
import useUsers from "@/use/use-users"
|
||||||
import useCurrentUser from "@/use/use-current-user"
|
import useCurrentUser from "@/use/use-current-user"
|
||||||
|
|
||||||
|
import DashboardCard from "@/components/common/DashboardCard.vue"
|
||||||
|
|
||||||
const { isSystemAdmin } = useCurrentUser()
|
const { isSystemAdmin } = useCurrentUser()
|
||||||
|
|
||||||
const { totalCount: usersCount } = useUsers()
|
const { totalCount: usersCount } = useUsers()
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center mt-3">
|
||||||
|
<span class="text-h4 mt-1">{{ title }}</span>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-shuffle"
|
||||||
|
text="Shuffle"
|
||||||
|
@click="shuffle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-skeleton-loader
|
||||||
|
v-if="isLoading"
|
||||||
|
type="card"
|
||||||
|
/>
|
||||||
|
<div v-else-if="isEmpty(flashcards)"></div>
|
||||||
|
<div
|
||||||
|
v-else-if="isEmpty(queue)"
|
||||||
|
class="review-layout mt-10"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
size="64"
|
||||||
|
color="success"
|
||||||
|
>
|
||||||
|
mdi-check-circle-outline
|
||||||
|
</v-icon>
|
||||||
|
<div class="text-h5">Done!</div>
|
||||||
|
<div class="text-medium-emphasis">
|
||||||
|
{{ numberOfCorrect }} correct • {{ numberOfIncorrect }} incorrect
|
||||||
|
</div>
|
||||||
|
<div class="text-medium-emphasis text-caption">
|
||||||
|
{{ elapsedTime }}
|
||||||
|
</div>
|
||||||
|
<v-btn
|
||||||
|
text="Exit"
|
||||||
|
:to="{ name: 'FlashcardsPage' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="review-layout"
|
||||||
|
>
|
||||||
|
<FlashcardReviewCard
|
||||||
|
:key="queue[currentIndex].id"
|
||||||
|
class="mt-5"
|
||||||
|
:flashcard="queue[currentIndex]"
|
||||||
|
/>
|
||||||
|
<div class="review-nav">
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-chevron-left"
|
||||||
|
variant="text"
|
||||||
|
size="x-large"
|
||||||
|
:disabled="currentIndex === 0"
|
||||||
|
@click="prev"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
class="mx-3"
|
||||||
|
:size="mobile ? 'x-large' : 'default'"
|
||||||
|
color="error"
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-close"
|
||||||
|
@click="markCurrentCardIncorrect"
|
||||||
|
>
|
||||||
|
{{ numberOfIncorrect }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
class="mx-3"
|
||||||
|
:size="mobile ? 'x-large' : 'default'"
|
||||||
|
color="success"
|
||||||
|
variant="tonal"
|
||||||
|
append-icon="mdi-check"
|
||||||
|
@click="markCurrentCardCorrect"
|
||||||
|
>
|
||||||
|
{{ numberOfCorrect }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-chevron-right"
|
||||||
|
variant="text"
|
||||||
|
size="x-large"
|
||||||
|
:disabled="currentIndex === queue.length - 1"
|
||||||
|
@click="next"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { isEmpty, isNil } from "lodash"
|
||||||
|
import { computed, ref, watch } from "vue"
|
||||||
|
|
||||||
|
import { MAX_PER_PAGE } from "@/api/base-api"
|
||||||
|
import useFlashcardDeck from "@/use/use-flashcard-deck"
|
||||||
|
import useFlashcards, { type Flashcard, FlashcardQueryOptions } from "@/use/use-flashcards"
|
||||||
|
|
||||||
|
import FlashcardReviewCard from "@/components/flashcards/FlashcardReviewCard.vue"
|
||||||
|
import { useDisplay } from "vuetify"
|
||||||
|
|
||||||
|
const props = defineProps<{ flashcardDeckId: string }>()
|
||||||
|
|
||||||
|
const { mobile } = useDisplay()
|
||||||
|
|
||||||
|
const flashcardDeckIdAsNumber = computed(() => parseInt(props.flashcardDeckId))
|
||||||
|
const { flashcardDeck } = useFlashcardDeck(flashcardDeckIdAsNumber)
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
const deckName = flashcardDeck.value?.name ?? ""
|
||||||
|
const done = results.value.size
|
||||||
|
const total = flashcards.value.length
|
||||||
|
const progress = total > 0 ? ` (${done} / ${total})` : ""
|
||||||
|
return `${deckName}${progress}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const flashcardsQueryOptions = computed<FlashcardQueryOptions>(() => {
|
||||||
|
return {
|
||||||
|
where: {
|
||||||
|
flashcardDeckId: flashcardDeck.value?.id,
|
||||||
|
},
|
||||||
|
perPage: MAX_PER_PAGE,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { flashcards, isLoading } = useFlashcards(flashcardsQueryOptions, {
|
||||||
|
skipWatchIf: () => isNil(flashcardDeck.value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentIndex = ref(0)
|
||||||
|
const queue = ref<Flashcard[]>([])
|
||||||
|
const results = ref(new Map<number, "correct" | "incorrect">())
|
||||||
|
const startTime = ref<number | null>(null)
|
||||||
|
const endTime = ref<number | null>(null)
|
||||||
|
|
||||||
|
const elapsedTime = computed(() => {
|
||||||
|
if (startTime.value === null || endTime.value === null) return ""
|
||||||
|
const seconds = Math.floor((endTime.value - startTime.value) / 1000)
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = seconds % 60
|
||||||
|
return m > 0 ? `${m}m ${s}s` : `${s}s`
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
flashcards,
|
||||||
|
(newFlashcards) => {
|
||||||
|
queue.value = [...newFlashcards]
|
||||||
|
if (newFlashcards.length > 0) startTime.value = Date.now()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const numberOfCorrect = computed(
|
||||||
|
() => [...results.value.values()].filter((r) => r === "correct").length
|
||||||
|
)
|
||||||
|
const numberOfIncorrect = computed(
|
||||||
|
() => [...results.value.values()].filter((r) => r === "incorrect").length
|
||||||
|
)
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
if (currentIndex.value < queue.value.length - 1) {
|
||||||
|
currentIndex.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prev() {
|
||||||
|
if (currentIndex.value > 0) {
|
||||||
|
currentIndex.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCurrentCard(result: "correct" | "incorrect") {
|
||||||
|
const card = queue.value[currentIndex.value]
|
||||||
|
results.value = new Map(results.value).set(card.id, result)
|
||||||
|
queue.value.splice(currentIndex.value, 1)
|
||||||
|
if (queue.value.length === 0) {
|
||||||
|
endTime.value = Date.now()
|
||||||
|
} else if (currentIndex.value >= queue.value.length) {
|
||||||
|
currentIndex.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCurrentCardCorrect() {
|
||||||
|
markCurrentCard("correct")
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCurrentCardIncorrect() {
|
||||||
|
markCurrentCard("incorrect")
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffle() {
|
||||||
|
for (let i = queue.value.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[queue.value[i], queue.value[j]] = [queue.value[j], queue.value[i]]
|
||||||
|
}
|
||||||
|
currentIndex.value = 0
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.review-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+42
-1
@@ -8,6 +8,30 @@ import { authorizationGuard } from "@/utils/authorization-guards"
|
|||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
|
component: () => import("@/layouts/PublicLayout.vue"),
|
||||||
|
meta: { requiresAuth: false },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
redirect: { name: "HomePage" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "home",
|
||||||
|
name: "HomePage",
|
||||||
|
component: () => import("@/pages/HomePage.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BlogPage",
|
||||||
|
path: "blog",
|
||||||
|
component: () => import("@/pages/BlogPage.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "Blog",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/sign-in",
|
||||||
name: "SignInPage",
|
name: "SignInPage",
|
||||||
component: () => import("@/pages/SignInPage.vue"),
|
component: () => import("@/pages/SignInPage.vue"),
|
||||||
meta: { requiresAuth: false },
|
meta: { requiresAuth: false },
|
||||||
@@ -24,7 +48,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "",
|
path: "",
|
||||||
redirect: "sign-in",
|
redirect: { name: "DashboardPage" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "DashboardPage",
|
name: "DashboardPage",
|
||||||
@@ -34,6 +58,23 @@ const routes: RouteRecordRaw[] = [
|
|||||||
title: "Dashboard",
|
title: "Dashboard",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "FlashcardsPage",
|
||||||
|
path: "flashcards",
|
||||||
|
component: () => import("@/pages/FlashcardsPage.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "Flashcards",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "FlashcardDeckReviewPage",
|
||||||
|
path: "flashcard-deck/:flashcardDeckId/review",
|
||||||
|
component: () => import("@/pages/flashcard-deck-reviews/FlashcardDeckReviewPage.vue"),
|
||||||
|
props: true,
|
||||||
|
meta: {
|
||||||
|
title: "Deck review",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "",
|
path: "",
|
||||||
component: () => import("@/layouts/LayoutWithBreadcrumbs.vue"),
|
component: () => import("@/layouts/LayoutWithBreadcrumbs.vue"),
|
||||||
|
|||||||
+95
-107
@@ -1,204 +1,192 @@
|
|||||||
@use "./variables" as *;
|
@use "./variables" as *;
|
||||||
|
|
||||||
html {
|
html {
|
||||||
.bg-success {
|
.bg-success {
|
||||||
color: $white !important;
|
color: $white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-primary {
|
.bg-primary {
|
||||||
color: $white !important;
|
color: $white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-secondary {
|
.bg-secondary {
|
||||||
color: $white !important;
|
color: $white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-warning {
|
.bg-warning {
|
||||||
color: $white !important;
|
color: $white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-secondary-gradient {
|
.bg-secondary-gradient {
|
||||||
background: linear-gradient(287deg, rgb(var(--v-theme-primary)) .54%, #1bcaff 100.84%);
|
background: linear-gradient(287deg, rgb(var(--v-theme-primary)) 0.54%, #1bcaff 100.84%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.border,
|
.border,
|
||||||
.v-divider {
|
.v-divider {
|
||||||
border-color: rgba(var(--v-border-color)) !important;
|
border-color: rgba(var(--v-border-color)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avtar-border {
|
.avtar-border {
|
||||||
border: 2px solid rgb(var(--v-theme-surface)) !important;
|
border: 2px solid rgb(var(--v-theme-surface)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtext {
|
.subtext {
|
||||||
font-size: $font-size-root;
|
font-size: $font-size-root;
|
||||||
line-height: 1.75rem;
|
line-height: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-dialog {
|
.v-dialog {
|
||||||
&.dialog-mw {
|
&.dialog-mw {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.round-40 {
|
.round-40 {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.round-56 {
|
.round-56 {
|
||||||
height: 56px;
|
height: 56px;
|
||||||
width: 56px;
|
width: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.round-48 {
|
.round-48 {
|
||||||
height: 48px;
|
height: 48px;
|
||||||
width: 48px;
|
width: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.round-30 {
|
.round-30 {
|
||||||
height: 30px;
|
height: 30px;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lh-0 {
|
.lh-0 {
|
||||||
line-height: 0 !important;
|
line-height: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.lh-28 {
|
.lh-28 {
|
||||||
line-height: 28px !important;
|
line-height: 28px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lh-32 {
|
.lh-32 {
|
||||||
line-height: 32px !important;
|
line-height: 32px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.space-p-96 {
|
.space-p-96 {
|
||||||
padding: 96px 0 !important;
|
padding: 96px 0 !important;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-96 {
|
.ps-96 {
|
||||||
padding-inline-start: 96px !important;
|
padding-inline-start: 96px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pt-96 {
|
.pt-96 {
|
||||||
padding-top: 96px !important;
|
padding-top: 96px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.end-0 {
|
.end-0 {
|
||||||
inset-inline-end: 0;
|
inset-inline-end: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-0 {
|
.top-0 {
|
||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-scrollbar {
|
.no-scrollbar {
|
||||||
height: calc(100vh - 350px);
|
height: calc(100vh - 350px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg-chat-height {
|
.msg-chat-height {
|
||||||
height: calc(-500px + 100vh);
|
height: calc(-500px + 100vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width:991px) {
|
@media screen and (max-width: 991px) {
|
||||||
.overflow-x-reposive {
|
.overflow-x-reposive {
|
||||||
overflow-x: scroll;
|
overflow-x: scroll;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.border-m-none {
|
.border-m-none {
|
||||||
border: 0 !important
|
border: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-height:767px) {
|
@media screen and (max-height: 767px) {
|
||||||
.msg-chat-height {
|
.msg-chat-height {
|
||||||
height: calc(-315px + 100vh);
|
height: calc(-315px + 100vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.max-h-600 {
|
.max-h-600 {
|
||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
height: calc(100vh - 100px);
|
height: calc(100vh - 100px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.custom-hover-primary {
|
.custom-hover-primary {
|
||||||
|
.iconify {
|
||||||
|
color: rgb(255, 255, 255) !important;
|
||||||
|
|
||||||
|
@media screen and (max-width: 991px) {
|
||||||
|
color: rgba(var(--v-theme-textPrimary), 0.8) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(var(--v-theme-lightprimary), 0.1);
|
||||||
|
|
||||||
.iconify {
|
.iconify {
|
||||||
color: rgb(255, 255, 255) !important;
|
@media screen and (max-width: 991px) {
|
||||||
|
color: rgb(var(--v-theme-primary)) !important;
|
||||||
@media screen and (max-width:991px) {
|
}
|
||||||
color: rgba(var(--v-theme-textPrimary), 0.8) !important
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(var(--v-theme-lightprimary), 0.1);
|
|
||||||
|
|
||||||
.iconify {
|
|
||||||
@media screen and (max-width:991px) {
|
|
||||||
color: rgb(var(--v-theme-primary)) !important
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.custom-hover-primary-white {
|
.custom-hover-primary-white {
|
||||||
.iconify {
|
.iconify {
|
||||||
color: rgb(255, 255, 255) !important;
|
color: rgb(255, 255, 255) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
}
|
background-color: rgba(var(--v-theme-lightprimary), 0.1);
|
||||||
|
}
|
||||||
&:hover {
|
|
||||||
background-color: rgba(var(--v-theme-lightprimary), 0.1);
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-icon {
|
.no-icon {
|
||||||
|
.v-input__prepend,
|
||||||
.v-input__prepend,
|
.v-input__append {
|
||||||
.v-input__append {
|
display: none !important;
|
||||||
display: none !important;
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-white {
|
.bg-white {
|
||||||
background-color: rgb(255, 255, 255) !important;
|
background-color: rgb(255, 255, 255) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-badge {
|
.v-badge {
|
||||||
&.x-small-badge {
|
&.x-small-badge {
|
||||||
.v-badge__badge {
|
.v-badge__badge {
|
||||||
height: 6px !important;
|
height: 6px !important;
|
||||||
width: 6px !important;
|
width: 6px !important;
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.one-line {
|
.one-line {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 1;
|
-webkit-line-clamp: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.two-line {
|
.two-line {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.z-1 {
|
.z-1 {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
.single-line-alert {
|
.single-line-alert {
|
||||||
.v-alert__close,
|
.v-alert__close,
|
||||||
.v-alert__prepend {
|
.v-alert__prepend {
|
||||||
align-self: center !important;
|
align-self: center !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
@media (max-width: 500px) {
|
||||||
.single-line-alert {
|
.single-line-alert {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
.v-alert__append {
|
.v-alert__append {
|
||||||
margin-inline-start: 0px;
|
margin-inline-start: 0px;
|
||||||
}
|
|
||||||
.v-alert__close {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
.v-alert__content {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.v-alert__close {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.v-alert__content {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
|
.v-breadcrumbs {
|
||||||
.v-breadcrumbs{
|
.v-breadcrumbs-divider {
|
||||||
.v-breadcrumbs-divider{
|
padding: 0 0 !important;
|
||||||
padding: 0 0 !important;
|
}
|
||||||
}
|
.v-breadcrumbs-item--link {
|
||||||
.v-breadcrumbs-item--link{
|
text-decoration: none;
|
||||||
text-decoration: none;
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
.v-btn-group .v-btn {
|
.v-btn-group .v-btn {
|
||||||
height: inherit !important;
|
height: inherit !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-btn-group {
|
.v-btn-group {
|
||||||
border-color: rgb(var(--v-theme-borderColor)) !important;
|
border-color: rgb(var(--v-theme-borderColor)) !important;
|
||||||
}
|
}
|
||||||
.v-btn{
|
.v-btn {
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
border-radius: 30px;
|
border-radius: 30px;
|
||||||
|
|
||||||
&.v-btn--variant-elevated{
|
&.v-btn--variant-elevated {
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
.v-btn--slim{
|
.v-btn--slim {
|
||||||
padding: 0 15px;
|
padding: 0 15px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.v-btn--elevated:hover{
|
.v-btn--elevated:hover {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,70 @@
|
|||||||
// Outline Card
|
// Outline Card
|
||||||
.v-card--variant-outlined {
|
.v-card--variant-outlined {
|
||||||
border-color: rgba(var(--v-theme-borderColor)) !important;
|
border-color: rgba(var(--v-theme-borderColor)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-card--variant-elevated,
|
.v-card--variant-elevated,
|
||||||
.v-card--variant-flat {
|
.v-card--variant-flat {
|
||||||
color: rgb(var(--v-theme-textPrimary));
|
color: rgb(var(--v-theme-textPrimary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-hover {
|
.card-hover {
|
||||||
transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
|
transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
|
||||||
&:hover {
|
&:hover {
|
||||||
scale: 1.01;
|
scale: 1.01;
|
||||||
transition: all 0.1s ease-in 0s;
|
transition: all 0.1s ease-in 0s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-card {
|
.v-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
.color-inherits {
|
.color-inherits {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
.feature-card {
|
.feature-card {
|
||||||
.v-responsive__content {
|
.v-responsive__content {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
|
||||||
}
|
|
||||||
.v-timeline-divider__before,.v-timeline-divider__after {
|
|
||||||
background: rgba(var(--v-border-color), 1);
|
|
||||||
}
|
|
||||||
.v-card-text{
|
|
||||||
padding: 24px 24px;
|
|
||||||
}
|
|
||||||
.v-card-item{
|
|
||||||
padding: 24px 24px;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.v-timeline-divider__before,
|
||||||
|
.v-timeline-divider__after {
|
||||||
|
background: rgba(var(--v-border-color), 1);
|
||||||
|
}
|
||||||
|
.v-card-text {
|
||||||
|
padding: 24px 24px;
|
||||||
|
}
|
||||||
|
.v-card-item {
|
||||||
|
padding: 24px 24px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme cards
|
// Theme cards
|
||||||
.cardBordered {
|
.cardBordered {
|
||||||
.v-card {
|
.v-card {
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
border: 1px solid rgb(var(--v-theme-borderColor));
|
border: 1px solid rgb(var(--v-theme-borderColor));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.elevation-o-card{
|
.elevation-o-card {
|
||||||
.v-card-item{
|
.v-card-item {
|
||||||
padding: 0.625rem 1rem;
|
padding: 0.625rem 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title{
|
.card-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color:rgb(var(--v-theme-textPrimary));
|
color: rgb(var(--v-theme-textPrimary));
|
||||||
}
|
}
|
||||||
.card-subtitle{
|
.card-subtitle {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color:rgb(var(--v-theme-textSecondary));
|
color: rgb(var(--v-theme-textSecondary));
|
||||||
|
}
|
||||||
|
.dark-card-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgb(var(--v-theme-textPrimary));
|
||||||
}
|
}
|
||||||
.dark-card-title{
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
color:rgb(var(--v-theme-textPrimary));
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
.v-pagination__item--is-active .v-btn__overlay {
|
.v-pagination__item--is-active .v-btn__overlay {
|
||||||
opacity: 0.15 !important;
|
opacity: 0.15 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +1,86 @@
|
|||||||
.v-table {
|
.v-table {
|
||||||
|
&.datatabels {
|
||||||
|
&.productlist {
|
||||||
|
.v-data-table-header__content span {
|
||||||
|
color: rgb(var(--v-theme-textPrimary));
|
||||||
|
}
|
||||||
|
|
||||||
&.datatabels {
|
.v-toolbar {
|
||||||
|
.v-input__control {
|
||||||
&.productlist {
|
max-width: 300px;
|
||||||
.v-data-table-header__content span {
|
|
||||||
color: rgb(var(--v-theme-textPrimary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-toolbar {
|
|
||||||
.v-input__control {
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-toolbar__content {
|
|
||||||
height: auto !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
thead tr th:first-child {
|
|
||||||
padding-left: 0px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr td:first-child {
|
|
||||||
padding-left: 0px !important;
|
|
||||||
}
|
|
||||||
tbody tr td{
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-selection-control--dirty .v-selection-control__input>.v-icon {
|
.v-toolbar__content {
|
||||||
color: rgb(var(--v-theme-primary));
|
height: auto !important;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thead tr th:first-child {
|
||||||
|
padding-left: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr td:first-child {
|
||||||
|
padding-left: 0px !important;
|
||||||
|
}
|
||||||
|
tbody tr td {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.v-selection-control--dirty .v-selection-control__input > .v-icon {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 1368px) {
|
||||||
@media screen and (max-width:1368px) {
|
.v-table {
|
||||||
|
&.datatabels {
|
||||||
.v-table {
|
&.productlist {
|
||||||
|
.v-data-table-header__content span {
|
||||||
&.datatabels {
|
color: rgb(var(--v-theme-textPrimary));
|
||||||
|
|
||||||
&.productlist {
|
|
||||||
.v-data-table-header__content span {
|
|
||||||
color: rgb(var(--v-theme-textPrimary));
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
tbody {
|
|
||||||
tr {
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 14px 5px !important;
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
padding-left: 15px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
thead {
|
|
||||||
tr {
|
|
||||||
th {
|
|
||||||
padding: 14px 5px !important;
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
padding-left: 15px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
table {
|
||||||
|
tbody {
|
||||||
|
tr {
|
||||||
|
td {
|
||||||
|
padding: 14px 5px !important;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
padding-left: 15px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th {
|
||||||
|
padding: 14px 5px !important;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
padding-left: 15px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-pagination {
|
.v-pagination {
|
||||||
.v-pagination__list {
|
.v-pagination__list {
|
||||||
.v-pagination__item--is-active {
|
.v-pagination__item--is-active {
|
||||||
.v-btn {
|
.v-btn {
|
||||||
.v-btn__overlay {
|
.v-btn__overlay {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
|
||||||
|
|
||||||
background-color: rgb(var(--v-theme-grey100)) !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
background-color: rgb(var(--v-theme-grey100)) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user