Compare commits

..

35 Commits

Author SHA1 Message Date
burkkyy 133d607b2f Removing bad padding 2026-07-17 04:54:06 -07:00
burkkyy de28a67417 Adding a ton of stuff 2026-07-17 04:39:18 -07:00
burkkyy 8d9fe8e75d First draft of HomePageUpgrade from claude 2026-07-16 16:42:37 -07:00
burkkyy 9dfee98b84 UI upgrades
Build and Push Docker Image / build (push) Successful in 52s
2026-07-15 01:54:42 -07:00
burkkyy d33aef1680 formatting 2026-07-15 01:01:19 -07:00
burkkyy a0d5a7ec68 Prepping public app bar for mobile view 2026-07-15 00:45:28 -07:00
burkkyy 3468df3782 removing unessesary files
Build and Push Docker Image / build (push) Successful in 1m40s
2026-07-14 23:22:44 -07:00
burkkyy 4a675f7da4 rewrote dev command to python, and added prod 2026-07-14 23:21:24 -07:00
burkkyy 5b31972b0f adding in production compose file 2026-07-14 23:08:03 -07:00
burkkyy 02092692b6 UI upgrades
Build and Push Docker Image / build (push) Successful in 1m2s
2026-07-14 04:16:13 -07:00
burkkyy b7b473d079 Adding public pages
Build and Push Docker Image / build (push) Successful in 1m8s
2026-07-14 04:07:52 -07:00
burkkyy 1af13091e6 unused component 2026-07-14 04:07:25 -07:00
burkkyy 02ef729bf4 mobile usability upgrades
Build and Push Docker Image / build (push) Successful in 2m59s
2026-06-29 01:28:52 -07:00
burkkyy 5ca7ca2028 Mobile upgrades
Build and Push Docker Image / build (push) Successful in 2m28s
2026-06-29 01:14:30 -07:00
burkkyy 03555d4744 Get all cards for a deck
Build and Push Docker Image / build (push) Successful in 2m30s
2026-06-29 00:45:24 -07:00
burkkyy 0595285c25 Destory deck helper
Build and Push Docker Image / build (push) Successful in 2m58s
2026-06-29 00:17:15 -07:00
burkkyy 39afe80a6c Exit button 2026-06-29 00:06:25 -07:00
burkkyy 6161720955 Adding prog to review page 2026-06-29 00:04:27 -07:00
burkkyy b7eaa2ad74 Big quaility of life changes 2026-06-28 23:58:29 -07:00
burkkyy 4347cb9c91 Edit and delete flashcards! 2026-06-28 23:37:26 -07:00
burkkyy 6f68049120 Adding deck title to cards page 2026-06-28 23:29:07 -07:00
burkkyy f94aeca4cc using a kebab options for deck tree node 2026-06-28 23:25:44 -07:00
burkkyy 9777a4229a Two quick fixes for production
Build and Push Docker Image / build (push) Successful in 2m24s
2026-06-28 20:44:42 -07:00
burkkyy f7e474a011 make db ssl optional
Build and Push Docker Image / build (push) Successful in 2m24s
2026-06-27 03:20:16 -07:00
burkkyy 6d211a1e83 Associate package with this repo
Build and Push Docker Image / build (push) Successful in 38s
2026-06-27 02:43:03 -07:00
burkkyy 7b7330d9b4 Changes to support build
Build and Push Docker Image / build (push) Successful in 1m16s
2026-06-27 02:13:48 -07:00
burkkyy 19352c7fa6 Use vars.REGISTRY_URL
Build and Push Docker Image / build (push) Failing after 10s
2026-06-27 01:47:50 -07:00
burkkyy ec6b9d2403 swapping back to github.server_url as ssl was setup
Build and Push Docker Image / build (push) Failing after 5s
2026-06-27 01:36:18 -07:00
burkkyy e1664bf724 Using hardcoded url for now
Build and Push Docker Image / build (push) Failing after 5s
2026-06-26 19:38:24 -07:00
burkkyy d581c7b789 Fixes
Build and Push Docker Image / build (push) Failing after 6s
2026-06-26 19:35:01 -07:00
burkkyy 8457a97c5f Build and publish
Build and Push Docker Image / build (push) Failing after 2m7s
2026-06-26 19:20:00 -07:00
burkkyy 4b91ba0936 UI upgrade 2026-06-26 02:12:22 -07:00
burkkyy 8030e98d90 Viewability upgrade 2026-06-26 01:51:24 -07:00
burkkyy 4c9e503e4f Adding how long it took to do cards 2026-06-26 01:46:18 -07:00
burkkyy cb60ec8480 flashcards in the frontend!!! 2026-06-26 01:25:25 -07:00
124 changed files with 6367 additions and 2234 deletions
+16
View File
@@ -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"
+34
View File
@@ -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 }}
-2
View File
@@ -1,2 +0,0 @@
nodejs 20.10.0
ruby 3.2.2
+55
View File
@@ -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>
+1
View File
@@ -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
+4
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
// Public (unauthenticated) controllers
export { BlogPostsController } from "./blog-posts-controller"
+1
View File
@@ -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
+2 -2
View File
@@ -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: {
+2 -12
View File
@@ -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")
}
+8 -8
View File
@@ -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 },
}) })
} }
+69
View File
@@ -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
View File
@@ -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,
}
+108
View File
@@ -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
View File
@@ -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"],
})
} }
} }
+57
View File
@@ -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
View File
@@ -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"
+20 -5
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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"
+2
View File
@@ -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
View File
+1
View File
@@ -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
View File
@@ -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
+197 -239
View File
@@ -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 class DevHelper:
def __init__(self):
self.last_exit_status = 0
# Core logic # Core logic
def call(*args, **kwargs) def call(self, *args, **kwargs):
command = args[0] if not args:
method = COMMAND_TO_METHOD.fetch(command, command) return self.compose(*args, **kwargs)
if args.length.positive? && respond_to?(method)
public_send(method, *args.drop(1), **kwargs)
else
compose(*args, **kwargs)
end
end
def compose(*args, **kwargs) command, rest = args[0], args[1:]
command = compose_command(*args, **kwargs) method_name = COMMAND_TO_METHOD.get(command, command)
puts "Running: #{command}" unless kwargs[:slient] 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)
case kwargs[:execution_mode] def compose(self, *args, **kwargs):
when WAIT_FOR_PROCESS command = self._compose_command(*args, **kwargs)
wait_for_process_with_logging(command) if not kwargs.get("silent"):
else print(f"Running: {command}")
exec(command)
end if kwargs.get("execution_mode") == WAIT_FOR_PROCESS:
end self._wait_for_process_with_logging(command)
else:
os.execv("/bin/sh", ["/bin/sh", "-c", command])
# Primary command wrappers # Primary command wrappers
def build(*args, **kwargs) def build(self, *args, **kwargs):
compose(%w[build], *args, **kwargs) self.compose("build", *args, **kwargs)
end
def compile(*args, **kwargs) def compile(self, *args, **kwargs):
run(*%w[api npm run build], execution_mode: WAIT_FOR_PROCESS) self.run("api", "npm", "run", "build", execution_mode=WAIT_FOR_PROCESS)
exit($?.exitstatus) unless $?.success? if self.last_exit_status != 0:
run(*%w[web npm run build]) sys.exit(self.last_exit_status)
end self.run("web", "npm", "run", "build")
def up(*args, **kwargs) def up(self, *args, **kwargs):
compose(*%w[up --remove-orphans], *args, **kwargs) self.compose("up", "--remove-orphans", *args, **kwargs)
end
def down(*args, **kwargs) def down(self, *args, **kwargs):
compose(*%w[down --remove-orphans], *args, **kwargs) self.compose("down", "--remove-orphans", *args, **kwargs)
end
def logs(*args, **kwargs) def logs(self, *args, **kwargs):
compose(*%w[logs -f], *args, **kwargs) self.compose("logs", "-f", *args, **kwargs)
end
def run(*args, **kwargs) def run(self, *args, **kwargs):
compose(*%w[run --rm], *args, **kwargs) self.compose("run", "--rm", *args, **kwargs)
end
def ps(*args, **kwargs) def ps(self, *args, **kwargs):
compose(*%w[ps], *args, **kwargs) self.compose("ps", *args, **kwargs)
end
# Custom helpers # Custom helpers
def api(*args, **kwargs) def api(self, *args, **kwargs):
run(*%w[api], *args, **kwargs) self.run("api", *args, **kwargs)
end
def web(*args, **kwargs) def web(self, *args, **kwargs):
run(*%w[web], *args, **kwargs) self.run("web", *args, **kwargs)
end
def check_types(*args, **kwargs) def check_types(self, *args, **kwargs):
run(*%w[api npm run check-types], *args, **kwargs) self.run("api", "npm", "run", "check-types", *args, **kwargs)
end
def test(*args, **kwargs) def test(self, *args, **kwargs):
service = args[0] service = args[0] if args else None
if service == "api" if service == "api":
test_api(*args.drop(1), **kwargs) self.test_api(*args[1:], **kwargs)
elsif service == "web" elif service == "web":
test_web(*args.drop(1), **kwargs) self.test_web(*args[1:], **kwargs)
else else:
test_api(*args, **kwargs) self.test_api(*args, **kwargs)
end
end
def test_api(*args, **kwargs) def test_api(self, *args, **kwargs):
reformat_project_relative_path_filter_for_vitest!(args, "api/") args = self._reformat_project_relative_path_filter_for_vitest(list(args), "api/")
run(*%w[test_api npm run test], *args, **kwargs) self.run("test_api", "npm", "run", "test", *args, **kwargs)
end
def test_web(*args, **kwargs) def test_web(self, *args, **kwargs):
reformat_project_relative_path_filter_for_vitest!(args, "web/") args = self._reformat_project_relative_path_filter_for_vitest(list(args), "web/")
run(*%w[test_web npm run test], *args, **kwargs) self.run("test_web", "npm", "run", "test", *args, **kwargs)
end
def sqlcmd(*args, **kwargs) def sqlcmd(self, *args, **kwargs):
db_host = ENV.fetch('DB_HOST', 'localhost') db_host = os.environ.get("DB_HOST", "localhost")
db_user = ENV.fetch('DB_USER', 'sa') db_user = os.environ.get("DB_USER", "sa")
db_pass = ENV.fetch('DB_PASS', '1m5ecure!') db_pass = os.environ.get("DB_PASS", "1m5ecure!")
db_name = ENV.fetch('DB_NAME', 'YHSI') db_name = os.environ.get("DB_NAME", "YHSI")
compose( self.compose(
*%w[exec db /opt/mssql-tools/bin/sqlcmd], "exec",
*%W[-U #{db_user}], "db",
*%W[-P #{db_pass}], "/opt/mssql-tools/bin/sqlcmd",
*%W[-H #{db_host}], "-U",
*%W[-d #{db_name}], db_user,
'-I', # enable quoted identifiers, e.g. "table"."column" "-P",
db_pass,
"-H",
db_host,
"-d",
db_name,
"-I", # enable quoted identifiers, e.g. "table"."column"
*args, *args,
**kwargs **kwargs,
) )
end
def db(*args, **kwargs) def db(self, *args, **kwargs):
compose(*%w[exec db], *args, **kwargs) self.compose("exec", "db", *args, **kwargs)
end
def debug def debug(self, *args, **kwargs):
api_container_id = container_id("api") container_id = self._container_id("api")
puts "Waiting for breakpoint to trigger..." print("Waiting for breakpoint to trigger...")
puts "'ctrl-c' to exit." print("'ctrl-c' to exit.")
command = "docker attach --detach-keys ctrl-c #{api_container_id}" command = f"docker attach --detach-keys ctrl-c {container_id}"
puts "Running: #{command}" print(f"Running: {command}")
exec(command) os.execv("/bin/sh", ["/bin/sh", "-c", command])
exit 0
end
def npm(*args, **kwargs) def npm(self, *args, **kwargs):
run(*%w[api npm], *args, **kwargs) self.run("api", "npm", *args, **kwargs)
end
def ts_node(*args, **kwargs) def ts_node(self, *args, **kwargs):
run(*%w[api npm run ts-node], *args, **kwargs) self.run("api", "npm", "run", "ts-node", *args, **kwargs)
end
def knex(*args, **kwargs) def knex(self, *args, **kwargs):
if RUBY_PLATFORM =~ /linux/ if sys.platform.startswith("linux"):
run(*%w[api npm run knex], *args, execution_mode: WAIT_FOR_PROCESS, **kwargs) self.run("api", "npm", "run", "knex", *args, execution_mode=WAIT_FOR_PROCESS, **kwargs)
file_or_directory = "#{project_root}/api/src/db/migrations" file_or_directory = os.path.join(self._project_root(), "api/src/db/migrations")
exit(0) unless take_over_needed?(file_or_directory) if not self._take_over_needed(file_or_directory):
sys.exit(0)
ownit file_or_directory self.ownit(file_or_directory)
else else:
run(*%w[api npm run knex], *args, **kwargs) self.run("api", "npm", "run", "knex", *args, **kwargs)
end
end
def migrate(*args, **kwargs) def migrate(self, *args, **kwargs):
action = args[0] action = args[0] if args else None
knex("migrate:#{action}", *args.drop(1), **kwargs) self.knex(f"migrate:{action}", *args[1:], **kwargs)
end
def seed(*args, **kwargs) def seed(self, *args, **kwargs):
action = args[0] action = args[0] if args else None
knex("seed:#{action}", *args.drop(1), **kwargs) self.knex(f"seed:{action}", *args[1:], **kwargs)
end
def ownit(*args, **kwargs) def ownit(self, *args, **kwargs):
file_or_directory = args[0] file_or_directory = args[0] if args else None
raise ScriptError, "Must provide a file or directory path." if file_or_directory.nil? if file_or_directory is None:
raise ValueError("Must provide a file or directory path.")
if RUBY_PLATFORM =~ /linux/ if sys.platform.startswith("linux"):
puts "Take ownership of the file or directory? #{file_or_directory}" print(f"Take ownership of the file or directory? {file_or_directory}")
exec("sudo chown -R #{user_id}:#{group_id} #{file_or_directory}") command = f"sudo chown -R {self._user_id()}:{self._group_id()} {file_or_directory}"
else os.execv("/bin/sh", ["/bin/sh", "-c", command])
raise NotImplementedError, "Not implement for platform #{RUBY_PLATFORM}" else:
end raise NotImplementedError(f"Not implemented for platform {sys.platform}")
end
def plantuml_to_png(*args, **kwargs) def plantuml_to_png(self, *args, **kwargs):
file_path = args.pop args = list(args)
raise ScriptError, "Must provide a file path." if file_path.nil? if not args:
raise ValueError("Must provide a file path.")
file_path = args.pop()
png_path = file_path.gsub(/\.(wsd|pu|puml|plantuml|uml)$/, ".png") png_path = re.sub(r"\.(wsd|pu|puml|plantuml|uml)$", ".png", file_path)
command = <<~BASH command = f"curl {' '.join(args)} --data-binary @'{file_path}' http://localhost:9999/png > '{png_path}'"
curl #{args.join(" ")} \
--data-binary @'#{file_path}' \
http://localhost:9999/png > '#{png_path}'
BASH
puts "Running: #{command}" print(f"Running: {command}")
exec(command) os.execv("/bin/sh", ["/bin/sh", "-c", command])
end
def bash_completions def bash_completions(self, *args, **kwargs):
completions = completions = sorted(
public_methods(false) METHOD_TO_COMMAND.get(name, name)
.reject { |word| %i[call].include?(word) } for name in vars(DevHelper)
.map { |word| METHOD_TO_COMMAND.fetch(word, word) } if not name.startswith("_") and name != "call" and callable(getattr(DevHelper, name))
puts completions )
end print(" ".join(completions))
private # 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 wait_for_process_with_logging(command) def _container_id(self, container_name, *args, **kwargs):
IO.popen("#{command} 2>&1") do |io| command = self._compose_command("ps", "-q", container_name, *args, **kwargs)
until io.eof? print(f"Running: {command}")
line = io.gets result = subprocess.run(["/bin/sh", "-c", command], stdout=subprocess.PIPE, text=True)
puts line container_id = result.stdout.strip()
end print(f"Container id is: {container_id}")
end return container_id
end
def container_id(container_name, *args, **kwargs) def _service_running(self, container_name):
command = compose_command(*%w[ps -q], container_name, *args, **kwargs) self.ps("-q", "--status=running", execution_mode=WAIT_FOR_PROCESS, silent=True)
puts "Running: #{command}" return self.last_exit_status == 0
id_of_container = `#{command}`.chomp
puts "Container id is: #{id_of_container}"
id_of_container
end
def service_running?(container_name) def _compose_command(self, *args, **kwargs):
ps(*%w[-q --status=running], execution_mode: WAIT_FOR_PROCESS, slient: true) != "" environment = kwargs.get("environment", "development")
end return f"cd {self._project_root()} && docker compose -f docker-compose.{environment}.yml {' '.join(args)}"
def compose_command(*args, **kwargs) def _project_root(self):
environment = kwargs.fetch(:environment, "development") return os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
"cd #{project_root} && docker compose -f docker-compose.#{environment}.yml #{args.join(" ")}"
end
def project_root def _take_over_needed(self, file_or_directory):
@project_root ||= File.absolute_path("#{__dir__}/..") result = subprocess.run(
end ["/bin/sh", "-c", f"find {file_or_directory} -not -user {self._user_id()} -print -quit | grep -q ."],
)
return result.returncode == 0
def take_over_needed?(file_or_directory) def _user_id(self):
files_owned_by_others = if not sys.platform.startswith("linux"):
system("find #{file_or_directory} -not -user #{user_id} -print -quit | grep -q .") raise NotImplementedError(f"Not implemented for platform {sys.platform}")
files_owned_by_others return subprocess.run(["id", "-u"], stdout=subprocess.PIPE, text=True).stdout.strip()
end
def user_id def _group_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", "-g"], stdout=subprocess.PIPE, text=True).stdout.strip()
`id -u`.strip def _reformat_project_relative_path_filter_for_vitest(self, args, prefix):
end if args and args[0].startswith(prefix):
src_path_prefix = f"{prefix}src/"
def group_id if args[0].startswith(src_path_prefix):
unless RUBY_PLATFORM =~ /linux/
raise NotImplementedError, "Not implement for platform #{RUBY_PLATFORM}"
end
`id -g`.strip
end
def reformat_project_relative_path_filter_for_vitest!(args, prefix)
if args.length.positive? && args[0].start_with?(prefix)
src_path_prefix = "#{prefix}src/"
test_path_regex = Regexp.escape(prefix)
src_path_regex = Regexp.escape(src_path_prefix)
if args[0].start_with?(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:])
Executable
+82
View File
@@ -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:])
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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"
/> />
+67
View File
@@ -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
+4 -1
View File
@@ -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<{
+4 -2
View File
@@ -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}`
} }
+48
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
export { publicBlogPostsApi } from "./blog-posts-api"
+1
View File
@@ -0,0 +1 @@
export { preferencesApi } from "./preferences-api"
+32
View File
@@ -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>
+2 -23
View File
@@ -4,16 +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"
>
<h1 <h1
class="mt-1 ml-3" class="mt-1 ml-3"
color="primary" color="primary"
@@ -26,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 ?? "&nbsp;" }}</span>
<v-icon
size="large"
:color="color"
>
mdi-chevron-right
</v-icon>
</div>
<div class="text-caption text-medium-emphasis mt-1">{{ countLabel ?? "&nbsp;" }}</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>
+64 -2
View File
@@ -1,4 +1,26 @@
<template> <template>
<div v-if="mobile">
<v-app-bar
id="top"
elevation="6"
height="60"
extension-height="48"
class="main-head pl-2"
>
<div class="mr-3">
<!-- <AppLogo /> -->
<PublicAppLogo
to="/dashboard"
:animated="false"
/>
</div>
<v-spacer />
<ProfileMenu />
</v-app-bar>
</div>
<div v-else>
<v-app-bar <v-app-bar
id="top" id="top"
elevation="6" elevation="6"
@@ -6,7 +28,20 @@
class="main-head pl-2" class="main-head pl-2"
> >
<div class="mr-3"> <div class="mr-3">
<AppLogo /> <!-- <AppLogo /> -->
<PublicAppLogo
to="/"
:animated="false"
/>
</div>
<div class="d-flex align-center">
<v-divider vertical />
<ExactingBreadcrumbs
class="appbar-title"
density="compact"
:items="breadcrumbs"
/>
</div> </div>
<v-spacer /> <v-spacer />
@@ -30,9 +65,36 @@
<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>
+2 -2
View File
@@ -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
View File
@@ -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",
} }
+224
View File
@@ -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>&copy; {{ 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>
-50
View File
@@ -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>
+36
View File
@@ -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>
+56 -1
View File
@@ -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>
+165
View File
@@ -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>
+392
View File
@@ -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">&#9608;</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&hellip;</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 &mdash; 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 &mdash; 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>
+2 -30
View File
@@ -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"
<v-card-item> :count="usersCount"
<div class="d-flex align-center"> count-label="Total Users"
<v-avatar
color="success" color="success"
size="56"
class="mr-4"
> >
<template #icon>
<v-icon <v-icon
size="32" size="32"
color="white" color="white"
> >
mdi-account-group mdi-account-group
</v-icon> </v-icon>
</v-avatar> </template>
<div> </DashboardCard>
<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"
<v-card-item> count-label="Configure System"
<div class="d-flex align-center">
<v-avatar
color="info" color="info"
size="56"
class="mr-4"
> >
<template #icon>
<v-icon <v-icon
size="32" size="32"
color="white" color="white"
> >
mdi-cog mdi-cog
</v-icon> </v-icon>
</v-avatar> </template>
<div> </DashboardCard>
<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">&nbsp;</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 &bull; {{ 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>
View File
+42 -1
View File
@@ -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"),
+3 -4
View File
@@ -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;
} }
} }
+4 -4
View File
@@ -5,18 +5,18 @@
.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;
} }
+12 -11
View File
@@ -27,13 +27,14 @@
height: 100%; height: 100%;
} }
} }
.v-timeline-divider__before,.v-timeline-divider__after { .v-timeline-divider__before,
.v-timeline-divider__after {
background: rgba(var(--v-border-color), 1); background: rgba(var(--v-border-color), 1);
} }
.v-card-text{ .v-card-text {
padding: 24px 24px; padding: 24px 24px;
} }
.v-card-item{ .v-card-item {
padding: 24px 24px; padding: 24px 24px;
} }
} }
@@ -46,24 +47,24 @@
} }
} }
.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{ .dark-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));
} }
+3 -16
View File
@@ -1,7 +1,5 @@
.v-table { .v-table {
&.datatabels { &.datatabels {
&.productlist { &.productlist {
.v-data-table-header__content span { .v-data-table-header__content span {
color: rgb(var(--v-theme-textPrimary)); color: rgb(var(--v-theme-textPrimary));
@@ -24,26 +22,20 @@
tbody tr td:first-child { tbody tr td:first-child {
padding-left: 0px !important; padding-left: 0px !important;
} }
tbody tr td{ tbody tr td {
padding: 15px; padding: 15px;
} }
} }
.v-selection-control--dirty .v-selection-control__input>.v-icon { .v-selection-control--dirty .v-selection-control__input > .v-icon {
color: rgb(var(--v-theme-primary)); color: rgb(var(--v-theme-primary));
} }
} }
} }
@media screen and (max-width: 1368px) {
@media screen and (max-width:1368px) {
.v-table { .v-table {
&.datatabels { &.datatabels {
&.productlist { &.productlist {
.v-data-table-header__content span { .v-data-table-header__content span {
color: rgb(var(--v-theme-textPrimary)); color: rgb(var(--v-theme-textPrimary));
@@ -52,7 +44,6 @@
table { table {
tbody { tbody {
tr { tr {
td { td {
padding: 14px 5px !important; padding: 14px 5px !important;
@@ -75,13 +66,9 @@
} }
} }
} }
} }
} }
} }
} }
.v-pagination { .v-pagination {
@@ -1,4 +1,4 @@
.v-expansion-panel-title__overlay{ .v-expansion-panel-title__overlay {
background: rgba(var(--v-theme-primary)); background: rgba(var(--v-theme-primary));
} }
.v-expansion-panel:not(:first-child)::after { .v-expansion-panel:not(:first-child)::after {
+1 -2
View File
@@ -23,7 +23,6 @@
opacity: 1; opacity: 1;
} }
.v-field--active .v-label.v-field-label {
.v-field--active .v-label.v-field-label{
color: rgb(var(--v-theme-textPrimary)); color: rgb(var(--v-theme-textPrimary));
} }
+5 -4
View File
@@ -1,10 +1,11 @@
.v-time-picker-clock{ .v-time-picker-clock {
background: rgb(var(--v-theme-grey100)) ; background: rgb(var(--v-theme-grey100));
} }
.v-time-picker-controls__ampm__btn.v-btn.v-btn--density-default{ .v-time-picker-controls__ampm__btn.v-btn.v-btn--density-default {
border: 0 !important; border: 0 !important;
} }
.v-stepper-header,.v-stepper.v-sheet{ .v-stepper-header,
.v-stepper.v-sheet {
box-shadow: none !important; box-shadow: none !important;
} }
+4 -4
View File
@@ -17,18 +17,18 @@
.v-list-item__overlay { .v-list-item__overlay {
background-color: rgb(var(--v-theme-hoverColor)); background-color: rgb(var(--v-theme-hoverColor));
} }
.v-list-item.v-list-item--active{ .v-list-item.v-list-item--active {
.v-list-item__overlay{ .v-list-item__overlay {
opacity: 1; opacity: 1;
} }
} }
.mail-items{ .mail-items {
min-height: 40px !important; min-height: 40px !important;
margin-bottom: 5px !important; margin-bottom: 5px !important;
} }
} }
.v-list-item-title{ .v-list-item-title {
font-size: 14px; font-size: 14px;
} }
+10 -10
View File
@@ -8,26 +8,26 @@
box-shadow: $box-shadow !important; box-shadow: $box-shadow !important;
} }
.elevation-1 { .elevation-1 {
box-shadow:0px 12px 30px -2px rgba(58,75,116,0.14) !important box-shadow: 0px 12px 30px -2px rgba(58, 75, 116, 0.14) !important;
} }
.elevation-2 { .elevation-2 {
box-shadow:0px 24px 24px -12px rgba(0, 0, 0, .05) !important box-shadow: 0px 24px 24px -12px rgba(0, 0, 0, 0.05) !important;
} }
.elevation-3 { .elevation-3 {
box-shadow: rgba(145,158,171,0.2) 0px 0px 2px 0px, rgba(145,158,171,0.12) 0px 12px 24px -4px !important; box-shadow:
rgba(145, 158, 171, 0.2) 0px 0px 2px 0px,
rgba(145, 158, 171, 0.12) 0px 12px 24px -4px !important;
} }
.elevation-4{ .elevation-4 {
box-shadow: 0px 12px 12px -6px rgba(0,0,0,0.15) !important; box-shadow: 0px 12px 12px -6px rgba(0, 0, 0, 0.15) !important;
} }
.elevation-5 .elevation-5 {
{ box-shadow: 1px 0 7px rgba(0, 0, 0, 0.05) !important;
box-shadow: 1px 0 7px rgba(0, 0, 0, .05)!important;
} }
.primary-shadow { .primary-shadow {
box-shadow: rgba(var(--v-theme-primary), 0.30) 0px 12px 14px 0px; box-shadow: rgba(var(--v-theme-primary), 0.3) 0px 12px 14px 0px;
&:hover { &:hover {
box-shadow: none; box-shadow: none;
} }
} }
+2 -1
View File
@@ -1,4 +1,5 @@
.v-stepper-item--selected .v-stepper-item__avatar.v-avatar, .v-stepper-item--complete .v-stepper-item__avatar.v-avatar { .v-stepper-item--selected .v-stepper-item__avatar.v-avatar,
.v-stepper-item--complete .v-stepper-item__avatar.v-avatar {
background: rgb(var(--v-theme-primary)) !important; background: rgb(var(--v-theme-primary)) !important;
} }

Some files were not shown because too many files have changed in this diff Show More