Compare commits

...

11 Commits

Author SHA1 Message Date
burkkyy 1a61286aa7 Merge pull request 'Removing bad padding' (#3) from blog into main
Build and Push Docker Image / build (push) Successful in 51s
Reviewed-on: #3
2026-07-17 04:54:27 -07:00
burkkyy 133d607b2f Removing bad padding 2026-07-17 04:54:06 -07:00
burkkyy b136d28fec Merge pull request 'BIg changes' (#2) from blog into main
Build and Push Docker Image / build (push) Successful in 59s
Reviewed-on: #2
2026-07-17 04:40:11 -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
96 changed files with 4251 additions and 2251 deletions
-2
View File
@@ -1,2 +0,0 @@
nodejs 20.10.0
ruby 3.2.2
@@ -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
@@ -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
@@ -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
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
+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
+200 -242
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
# Core logic
def call(*args, **kwargs)
command = args[0]
method = COMMAND_TO_METHOD.fetch(command, command)
if args.length.positive? && respond_to?(method)
public_send(method, *args.drop(1), **kwargs)
else
compose(*args, **kwargs)
end
end
def compose(*args, **kwargs) class DevHelper:
command = compose_command(*args, **kwargs) def __init__(self):
puts "Running: #{command}" unless kwargs[:slient] self.last_exit_status = 0
case kwargs[:execution_mode] # Core logic
when WAIT_FOR_PROCESS def call(self, *args, **kwargs):
wait_for_process_with_logging(command) if not args:
else return self.compose(*args, **kwargs)
exec(command)
end
end
# Primary command wrappers command, rest = args[0], args[1:]
def build(*args, **kwargs) method_name = COMMAND_TO_METHOD.get(command, command)
compose(%w[build], *args, **kwargs) method = getattr(self, method_name, None)
end if callable(method) and not method_name.startswith("_") and method_name != "call":
return method(*rest, **kwargs)
return self.compose(*args, **kwargs)
def compile(*args, **kwargs) def compose(self, *args, **kwargs):
run(*%w[api npm run build], execution_mode: WAIT_FOR_PROCESS) command = self._compose_command(*args, **kwargs)
exit($?.exitstatus) unless $?.success? if not kwargs.get("silent"):
run(*%w[web npm run build]) print(f"Running: {command}")
end
def up(*args, **kwargs) if kwargs.get("execution_mode") == WAIT_FOR_PROCESS:
compose(*%w[up --remove-orphans], *args, **kwargs) self._wait_for_process_with_logging(command)
end else:
os.execv("/bin/sh", ["/bin/sh", "-c", command])
def down(*args, **kwargs) # Primary command wrappers
compose(*%w[down --remove-orphans], *args, **kwargs) def build(self, *args, **kwargs):
end self.compose("build", *args, **kwargs)
def logs(*args, **kwargs) def compile(self, *args, **kwargs):
compose(*%w[logs -f], *args, **kwargs) self.run("api", "npm", "run", "build", execution_mode=WAIT_FOR_PROCESS)
end if self.last_exit_status != 0:
sys.exit(self.last_exit_status)
self.run("web", "npm", "run", "build")
def run(*args, **kwargs) def up(self, *args, **kwargs):
compose(*%w[run --rm], *args, **kwargs) self.compose("up", "--remove-orphans", *args, **kwargs)
end
def ps(*args, **kwargs) def down(self, *args, **kwargs):
compose(*%w[ps], *args, **kwargs) self.compose("down", "--remove-orphans", *args, **kwargs)
end
# Custom helpers def logs(self, *args, **kwargs):
def api(*args, **kwargs) self.compose("logs", "-f", *args, **kwargs)
run(*%w[api], *args, **kwargs)
end
def web(*args, **kwargs) def run(self, *args, **kwargs):
run(*%w[web], *args, **kwargs) self.compose("run", "--rm", *args, **kwargs)
end
def check_types(*args, **kwargs) def ps(self, *args, **kwargs):
run(*%w[api npm run check-types], *args, **kwargs) self.compose("ps", *args, **kwargs)
end
def test(*args, **kwargs) # Custom helpers
service = args[0] def api(self, *args, **kwargs):
if service == "api" self.run("api", *args, **kwargs)
test_api(*args.drop(1), **kwargs)
elsif service == "web"
test_web(*args.drop(1), **kwargs)
else
test_api(*args, **kwargs)
end
end
def test_api(*args, **kwargs) def web(self, *args, **kwargs):
reformat_project_relative_path_filter_for_vitest!(args, "api/") self.run("web", *args, **kwargs)
run(*%w[test_api npm run test], *args, **kwargs)
end
def test_web(*args, **kwargs) def check_types(self, *args, **kwargs):
reformat_project_relative_path_filter_for_vitest!(args, "web/") self.run("api", "npm", "run", "check-types", *args, **kwargs)
run(*%w[test_web npm run test], *args, **kwargs)
end
def sqlcmd(*args, **kwargs) def test(self, *args, **kwargs):
db_host = ENV.fetch('DB_HOST', 'localhost') service = args[0] if args else None
db_user = ENV.fetch('DB_USER', 'sa') if service == "api":
db_pass = ENV.fetch('DB_PASS', '1m5ecure!') self.test_api(*args[1:], **kwargs)
db_name = ENV.fetch('DB_NAME', 'YHSI') elif service == "web":
compose( self.test_web(*args[1:], **kwargs)
*%w[exec db /opt/mssql-tools/bin/sqlcmd], else:
*%W[-U #{db_user}], self.test_api(*args, **kwargs)
*%W[-P #{db_pass}],
*%W[-H #{db_host}],
*%W[-d #{db_name}],
'-I', # enable quoted identifiers, e.g. "table"."column"
*args,
**kwargs
)
end
def db(*args, **kwargs) def test_api(self, *args, **kwargs):
compose(*%w[exec db], *args, **kwargs) args = self._reformat_project_relative_path_filter_for_vitest(list(args), "api/")
end self.run("test_api", "npm", "run", "test", *args, **kwargs)
def debug def test_web(self, *args, **kwargs):
api_container_id = container_id("api") args = self._reformat_project_relative_path_filter_for_vitest(list(args), "web/")
puts "Waiting for breakpoint to trigger..." self.run("test_web", "npm", "run", "test", *args, **kwargs)
puts "'ctrl-c' to exit."
command = "docker attach --detach-keys ctrl-c #{api_container_id}"
puts "Running: #{command}"
exec(command)
exit 0
end
def npm(*args, **kwargs) def sqlcmd(self, *args, **kwargs):
run(*%w[api npm], *args, **kwargs) db_host = os.environ.get("DB_HOST", "localhost")
end db_user = os.environ.get("DB_USER", "sa")
db_pass = os.environ.get("DB_PASS", "1m5ecure!")
db_name = os.environ.get("DB_NAME", "YHSI")
self.compose(
"exec",
"db",
"/opt/mssql-tools/bin/sqlcmd",
"-U",
db_user,
"-P",
db_pass,
"-H",
db_host,
"-d",
db_name,
"-I", # enable quoted identifiers, e.g. "table"."column"
*args,
**kwargs,
)
def ts_node(*args, **kwargs) def db(self, *args, **kwargs):
run(*%w[api npm run ts-node], *args, **kwargs) self.compose("exec", "db", *args, **kwargs)
end
def knex(*args, **kwargs) def debug(self, *args, **kwargs):
if RUBY_PLATFORM =~ /linux/ container_id = self._container_id("api")
run(*%w[api npm run knex], *args, execution_mode: WAIT_FOR_PROCESS, **kwargs) print("Waiting for breakpoint to trigger...")
print("'ctrl-c' to exit.")
command = f"docker attach --detach-keys ctrl-c {container_id}"
print(f"Running: {command}")
os.execv("/bin/sh", ["/bin/sh", "-c", command])
file_or_directory = "#{project_root}/api/src/db/migrations" def npm(self, *args, **kwargs):
exit(0) unless take_over_needed?(file_or_directory) self.run("api", "npm", *args, **kwargs)
ownit file_or_directory def ts_node(self, *args, **kwargs):
else self.run("api", "npm", "run", "ts-node", *args, **kwargs)
run(*%w[api npm run knex], *args, **kwargs)
end
end
def migrate(*args, **kwargs) def knex(self, *args, **kwargs):
action = args[0] if sys.platform.startswith("linux"):
knex("migrate:#{action}", *args.drop(1), **kwargs) self.run("api", "npm", "run", "knex", *args, execution_mode=WAIT_FOR_PROCESS, **kwargs)
end
def seed(*args, **kwargs) file_or_directory = os.path.join(self._project_root(), "api/src/db/migrations")
action = args[0] if not self._take_over_needed(file_or_directory):
knex("seed:#{action}", *args.drop(1), **kwargs) sys.exit(0)
end
def ownit(*args, **kwargs) self.ownit(file_or_directory)
file_or_directory = args[0] else:
raise ScriptError, "Must provide a file or directory path." if file_or_directory.nil? self.run("api", "npm", "run", "knex", *args, **kwargs)
if RUBY_PLATFORM =~ /linux/ def migrate(self, *args, **kwargs):
puts "Take ownership of the file or directory? #{file_or_directory}" action = args[0] if args else None
exec("sudo chown -R #{user_id}:#{group_id} #{file_or_directory}") self.knex(f"migrate:{action}", *args[1:], **kwargs)
else
raise NotImplementedError, "Not implement for platform #{RUBY_PLATFORM}"
end
end
def plantuml_to_png(*args, **kwargs) def seed(self, *args, **kwargs):
file_path = args.pop action = args[0] if args else None
raise ScriptError, "Must provide a file path." if file_path.nil? self.knex(f"seed:{action}", *args[1:], **kwargs)
png_path = file_path.gsub(/\.(wsd|pu|puml|plantuml|uml)$/, ".png") def ownit(self, *args, **kwargs):
file_or_directory = args[0] if args else None
if file_or_directory is None:
raise ValueError("Must provide a file or directory path.")
command = <<~BASH if sys.platform.startswith("linux"):
curl #{args.join(" ")} \ print(f"Take ownership of the file or directory? {file_or_directory}")
--data-binary @'#{file_path}' \ command = f"sudo chown -R {self._user_id()}:{self._group_id()} {file_or_directory}"
http://localhost:9999/png > '#{png_path}' os.execv("/bin/sh", ["/bin/sh", "-c", command])
BASH else:
raise NotImplementedError(f"Not implemented for platform {sys.platform}")
puts "Running: #{command}" def plantuml_to_png(self, *args, **kwargs):
exec(command) args = list(args)
end if not args:
raise ValueError("Must provide a file path.")
file_path = args.pop()
def bash_completions png_path = re.sub(r"\.(wsd|pu|puml|plantuml|uml)$", ".png", file_path)
completions =
public_methods(false)
.reject { |word| %i[call].include?(word) }
.map { |word| METHOD_TO_COMMAND.fetch(word, word) }
puts completions
end
private command = f"curl {' '.join(args)} --data-binary @'{file_path}' http://localhost:9999/png > '{png_path}'"
def wait_for_process_with_logging(command) print(f"Running: {command}")
IO.popen("#{command} 2>&1") do |io| os.execv("/bin/sh", ["/bin/sh", "-c", command])
until io.eof?
line = io.gets
puts line
end
end
end
def container_id(container_name, *args, **kwargs) def bash_completions(self, *args, **kwargs):
command = compose_command(*%w[ps -q], container_name, *args, **kwargs) completions = sorted(
puts "Running: #{command}" METHOD_TO_COMMAND.get(name, name)
id_of_container = `#{command}`.chomp for name in vars(DevHelper)
puts "Container id is: #{id_of_container}" if not name.startswith("_") and name != "call" and callable(getattr(DevHelper, name))
id_of_container )
end print(" ".join(completions))
def service_running?(container_name) # Private helpers
ps(*%w[-q --status=running], execution_mode: WAIT_FOR_PROCESS, slient: true) != "" def _wait_for_process_with_logging(self, command):
end process = subprocess.Popen(
["/bin/sh", "-c", f"{command} 2>&1"],
stdout=subprocess.PIPE,
text=True,
)
for line in process.stdout:
print(line, end="")
process.wait()
self.last_exit_status = process.returncode
def compose_command(*args, **kwargs) def _container_id(self, container_name, *args, **kwargs):
environment = kwargs.fetch(:environment, "development") command = self._compose_command("ps", "-q", container_name, *args, **kwargs)
"cd #{project_root} && docker compose -f docker-compose.#{environment}.yml #{args.join(" ")}" print(f"Running: {command}")
end result = subprocess.run(["/bin/sh", "-c", command], stdout=subprocess.PIPE, text=True)
container_id = result.stdout.strip()
print(f"Container id is: {container_id}")
return container_id
def project_root def _service_running(self, container_name):
@project_root ||= File.absolute_path("#{__dir__}/..") self.ps("-q", "--status=running", execution_mode=WAIT_FOR_PROCESS, silent=True)
end return self.last_exit_status == 0
def take_over_needed?(file_or_directory) def _compose_command(self, *args, **kwargs):
files_owned_by_others = environment = kwargs.get("environment", "development")
system("find #{file_or_directory} -not -user #{user_id} -print -quit | grep -q .") return f"cd {self._project_root()} && docker compose -f docker-compose.{environment}.yml {' '.join(args)}"
files_owned_by_others
end
def user_id def _project_root(self):
unless RUBY_PLATFORM =~ /linux/ return os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
raise NotImplementedError, "Not implement for platform #{RUBY_PLATFORM}"
end
`id -u`.strip def _take_over_needed(self, file_or_directory):
end result = subprocess.run(
["/bin/sh", "-c", f"find {file_or_directory} -not -user {self._user_id()} -print -quit | grep -q ."],
)
return result.returncode == 0
def group_id def _user_id(self):
unless RUBY_PLATFORM =~ /linux/ if not sys.platform.startswith("linux"):
raise NotImplementedError, "Not implement for platform #{RUBY_PLATFORM}" raise NotImplementedError(f"Not implemented for platform {sys.platform}")
end return subprocess.run(["id", "-u"], stdout=subprocess.PIPE, text=True).stdout.strip()
`id -g`.strip def _group_id(self):
end if not sys.platform.startswith("linux"):
raise NotImplementedError(f"Not implemented for platform {sys.platform}")
return subprocess.run(["id", "-g"], stdout=subprocess.PIPE, text=True).stdout.strip()
def reformat_project_relative_path_filter_for_vitest!(args, prefix) def _reformat_project_relative_path_filter_for_vitest(self, args, prefix):
if args.length.positive? && args[0].start_with?(prefix) if args and args[0].startswith(prefix):
src_path_prefix = "#{prefix}src/" src_path_prefix = f"{prefix}src/"
test_path_regex = Regexp.escape(prefix)
src_path_regex = Regexp.escape(src_path_prefix)
if args[0].start_with?(src_path_prefix) if args[0].startswith(src_path_prefix):
# TODO: handle other file types # TODO: handle other file types
args[0] = args[0].gsub(/^#{src_path_regex}/, "tests/").gsub(/\.ts$/, ".test.ts") args[0] = re.sub(r"\.ts$", ".test.ts", re.sub(f"^{re.escape(src_path_prefix)}", "tests/", args[0]))
else else:
args[0] = args[0].gsub(/^#{test_path_regex}/, "") args[0] = re.sub(f"^{re.escape(prefix)}", "", args[0])
end
puts "Reformatted path filter from project relative to service relative for vitest." print("Reformatted path filter from project relative to service relative for vitest.")
end
end
end
# Only execute main function when file is executed return args
DevHelper.call(*ARGV) if $PROGRAM_NAME == __FILE__
## Dev completions
# https://iridakos.com/programming/2018/03/01/bash-programmable-completion-tutorial
# _dev_completions () {
# local dev_command_path="$(which dev)"
# local dev_function_names
# dev_function_names="$(ruby "$dev_command_path" bash_completions)"
# # COMP_WORDS: an array of all the words typed after the name of the program the compspec belongs to
# # COMP_CWORD: an index of the COMP_WORDS array pointing to the word the current cursor is at - in other words, the index of the word the cursor was when the tab key was pressed
# # COMP_LINE: the current command line
# COMPREPLY=($(compgen -W "$dev_function_names" "${COMP_WORDS[$COMP_CWORD]}"))
# }
# complete -F _dev_completions dev if __name__ == "__main__":
# complete -W "allow" direnv DevHelper().call(*sys.argv[1:])
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
+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>
@@ -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>
+1 -67
View File
@@ -8,86 +8,20 @@
<span class="term-green">caleb</span> <span class="term-green">caleb</span>
<span class="term-cyan">@</span> <span class="term-cyan">@</span>
<span class="term-green mr-2">burke</span> <span class="term-green mr-2">burke</span>
<span class="term-white">~</span>
<span class="term-cyan mr-2">$</span>
<span class="term-white">{{ typedText }}</span>
<span
v-if="animated"
class="term-cursor"
>
&#9608;
</span>
</div> </div>
</RouterLink> </RouterLink>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue" withDefaults(
const props = withDefaults(
defineProps<{ defineProps<{
to?: string to?: string
animated?: boolean
}>(), }>(),
{ {
to: "/dashboard", to: "/dashboard",
animated: false,
} }
) )
const TYPED_PHRASES = [
" Hello!",
" Cheers!",
" привет",
" クリア",
" Εβίβα!",
" 干杯",
" Будьмо!",
" Na zdraví!",
" 乾杯!",
]
const TYPE_DELAY_MS = 150
const DELETE_DELAY_MS = 100
const PAUSE_AFTER_TYPED_MS = 1500
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)
}
}
onMounted(() => {
if (!props.animated) return
currentPhrase = pickRandomPhrase()
typingTimeoutId = setTimeout(() => typeCharacter(0), INITIAL_DELAY_MS)
})
onUnmounted(() => {
clearTimeout(typingTimeoutId)
})
</script> </script>
<style> <style>
+30 -29
View File
@@ -18,22 +18,6 @@
<v-spacer /> <v-spacer />
<ProfileMenu /> <ProfileMenu />
<template #extension>
<v-tabs
class="ml-2"
color="primary"
>
<v-tab
:to="{ name: 'DashboardPage' }"
text="Dashboard"
/>
<v-tab
:to="{ name: 'FlashcardsPage' }"
text="Flashcards"
/>
</v-tabs>
</template>
</v-app-bar> </v-app-bar>
</div> </div>
<div v-else> <div v-else>
@@ -46,24 +30,19 @@
<div class="mr-3"> <div class="mr-3">
<!-- <AppLogo /> --> <!-- <AppLogo /> -->
<PublicAppLogo <PublicAppLogo
to="/dashboard" to="/"
:animated="false" :animated="false"
/> />
</div> </div>
<v-tabs <div class="d-flex align-center">
class="ml-2" <v-divider vertical />
color="primary" <ExactingBreadcrumbs
> class="appbar-title"
<v-tab density="compact"
:to="{ name: 'DashboardPage' }" :items="breadcrumbs"
text="Dashboard"
/> />
<v-tab </div>
:to="{ name: 'FlashcardsPage' }"
text="Flashcards"
/>
</v-tabs>
<v-spacer /> <v-spacer />
@@ -92,8 +71,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { useDisplay } from "vuetify" import { useDisplay } from "vuetify"
import useBreadcrumbs from "@/use/use-breadcrumbs"
import PublicAppLogo from "@/components/common/PublicAppLogo.vue" 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 { 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>
+50 -18
View File
@@ -1,27 +1,59 @@
<template> <template>
<v-app-bar <div v-if="mobile">
id="top" <v-app-bar
elevation="6" id="top"
height="60" elevation="6"
class="main-head pl-2" height="60"
> class="main-head pl-2"
<div class="mr-3"> >
<PublicAppLogo <div class="mr-3">
to="/" <PublicAppLogo to="/" />
:animated="isHomePage" </div>
/>
</div>
<v-spacer /> <v-spacer />
</v-app-bar>
<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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue" import { useDisplay } from "vuetify"
import { useRoute } from "vue-router" import { useAuth0 } from "@auth0/auth0-vue"
import ProfileMenu from "@/components/layout/ProfileMenu.vue"
import PublicAppLogo from "@/components/common/PublicAppLogo.vue" import PublicAppLogo from "@/components/common/PublicAppLogo.vue"
const route = useRoute() const { mobile } = useDisplay()
const isHomePage = computed(() => route.name === "HomePage") const { isAuthenticated } = useAuth0()
</script> </script>
+204 -2
View File
@@ -1,22 +1,224 @@
<template> <template>
<v-app> <v-app>
<PublicAppBar /> <PublicAppBar class="mb-2" />
<v-main> <v-main>
<v-container <v-container
fluid fluid
:class="mobile ? 'pa-2' : 'pa-4'" :class="mobile ? 'pa-2' : 'pa-4'"
> >
<router-view /> <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-container>
</v-main> </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> </v-app>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useDisplay } from "vuetify" import { useDisplay } from "vuetify"
import { useRoute } from "vue-router"
import { DateTime } from "luxon"
import PublicAppBar from "@/components/layout/PublicAppBar.vue" import PublicAppBar from "@/components/layout/PublicAppBar.vue"
const { mobile } = useDisplay() const { mobile } = useDisplay()
const route = useRoute()
const currentYear = DateTime.local().year
</script> </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>
+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>
+8 -1
View File
@@ -154,5 +154,12 @@ function openFlashcardCreateDialog() {
flashcardCreateDialog.value?.show() flashcardCreateDialog.value?.show()
} }
useBreadcrumbs("Study") useBreadcrumbs("Flashcards", [
{
title: "Flashcards",
to: {
name: "FlashcardsPage",
},
},
])
</script> </script>
+392 -1
View File
@@ -1 +1,392 @@
<template><div></div></template> <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"
:count="usersCount"
count-label="Total Users"
color="success"
> >
<v-card-item> <template #icon>
<div class="d-flex align-center"> <v-icon
<v-avatar size="32"
color="success" color="white"
size="56" >
class="mr-4" mdi-account-group
> </v-icon>
<v-icon </template>
size="32" </DashboardCard>
color="white"
>
mdi-account-group
</v-icon>
</v-avatar>
<div>
<v-card-title class="text-h5">Users</v-card-title>
<v-card-subtitle>Manage users and permissions</v-card-subtitle>
</div>
</div>
</v-card-item>
<v-divider />
<v-card-text>
<div class="d-flex align-center justify-space-between">
<span class="text-h3 font-weight-bold">{{ usersCount }}</span>
<v-icon
size="large"
color="success"
>
mdi-chevron-right
</v-icon>
</div>
<div class="text-caption text-medium-emphasis mt-1">Total Users</div>
</v-card-text>
</v-card>
</v-col> </v-col>
<!-- Settings Card -->
<v-col <v-col
v-if="isSystemAdmin" v-if="isSystemAdmin"
cols="12" cols="12"
md="6" md="6"
lg="4" lg="4"
> >
<v-card <DashboardCard
elevation="10"
:to="{ name: 'administration/SettingsPage' }" :to="{ name: 'administration/SettingsPage' }"
hover title="Settings"
subtitle="System configuration"
count-label="Configure System"
color="info"
> >
<v-card-item> <template #icon>
<div class="d-flex align-center"> <v-icon
<v-avatar size="32"
color="info" color="white"
size="56" >
class="mr-4" mdi-cog
> </v-icon>
<v-icon </template>
size="32" </DashboardCard>
color="white"
>
mdi-cog
</v-icon>
</v-avatar>
<div>
<v-card-title class="text-h5">Settings</v-card-title>
<v-card-subtitle>System configuration</v-card-subtitle>
</div>
</div>
</v-card-item>
<v-divider />
<v-card-text>
<div class="d-flex align-center justify-space-between">
<span class="text-h3 font-weight-bold">&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()
+13 -3
View File
@@ -4,7 +4,6 @@ import { authGuard } from "@auth0/auth0-vue"
import { APPLICATION_NAME } from "@/config" import { APPLICATION_NAME } from "@/config"
import administrationRoutes from "@/routes/administration-routes" import administrationRoutes from "@/routes/administration-routes"
import { authorizationGuard } from "@/utils/authorization-guards" import { authorizationGuard } from "@/utils/authorization-guards"
import publicRoutes from "@/routes/public-routes"
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
@@ -14,9 +13,21 @@ const routes: RouteRecordRaw[] = [
children: [ children: [
{ {
path: "", path: "",
redirect: { name: "HomePage" },
},
{
path: "home",
name: "HomePage", name: "HomePage",
component: () => import("@/pages/HomePage.vue"), component: () => import("@/pages/HomePage.vue"),
}, },
{
name: "BlogPage",
path: "blog",
component: () => import("@/pages/BlogPage.vue"),
meta: {
title: "Blog",
},
},
], ],
}, },
{ {
@@ -37,7 +48,7 @@ const routes: RouteRecordRaw[] = [
children: [ children: [
{ {
path: "", path: "",
redirect: "sign-in", redirect: { name: "DashboardPage" },
}, },
{ {
name: "DashboardPage", name: "DashboardPage",
@@ -95,7 +106,6 @@ const routes: RouteRecordRaw[] = [
}, },
], ],
}, },
...publicRoutes,
...administrationRoutes, ...administrationRoutes,
{ {
name: "StatusPage", name: "StatusPage",
-12
View File
@@ -1,12 +0,0 @@
import { RouteRecordRaw } from "vue-router"
export const publicRoutes: Readonly<RouteRecordRaw[]> = [
{
path: "/public",
component: () => import("@/layouts/PublicLayout.vue"),
meta: { requiresAuth: false },
children: [],
},
]
export default publicRoutes
+18 -18
View File
@@ -1,24 +1,24 @@
.single-line-alert { .single-line-alert {
.v-alert__close, .v-alert__close,
.v-alert__prepend { .v-alert__prepend {
align-self: center !important; align-self: center !important;
} }
} }
@media (max-width: 500px) { @media (max-width: 500px) {
.single-line-alert { .single-line-alert {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
.v-alert__append { .v-alert__append {
margin-inline-start: 0px; margin-inline-start: 0px;
}
.v-alert__close {
margin-left: auto;
}
.v-alert__content {
width: 100%;
margin-top: 5px;
}
} }
.v-alert__close {
margin-left: auto;
}
.v-alert__content {
width: 100%;
margin-top: 5px;
}
}
} }
+7 -8
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; }
}
} }
+14 -14
View File
@@ -1,22 +1,22 @@
.v-btn-group .v-btn { .v-btn-group .v-btn {
height: inherit !important; height: inherit !important;
} }
.v-btn-group { .v-btn-group {
border-color: rgb(var(--v-theme-borderColor)) !important; border-color: rgb(var(--v-theme-borderColor)) !important;
} }
.v-btn{ .v-btn {
text-transform: capitalize; text-transform: capitalize;
letter-spacing: 0; letter-spacing: 0;
border-radius: 30px; border-radius: 30px;
&.v-btn--variant-elevated{ &.v-btn--variant-elevated {
box-shadow: none !important; box-shadow: none !important;
} }
.v-btn--slim{ .v-btn--slim {
padding: 0 15px; padding: 0 15px;
} }
} }
.v-btn--elevated:hover{ .v-btn--elevated:hover {
box-shadow: none; box-shadow: none;
} }
+47 -46
View File
@@ -1,69 +1,70 @@
// Outline Card // Outline Card
.v-card--variant-outlined { .v-card--variant-outlined {
border-color: rgba(var(--v-theme-borderColor)) !important; border-color: rgba(var(--v-theme-borderColor)) !important;
} }
.v-card--variant-elevated, .v-card--variant-elevated,
.v-card--variant-flat { .v-card--variant-flat {
color: rgb(var(--v-theme-textPrimary)); color: rgb(var(--v-theme-textPrimary));
} }
.card-hover { .card-hover {
transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
&:hover { &:hover {
scale: 1.01; scale: 1.01;
transition: all 0.1s ease-in 0s; transition: all 0.1s ease-in 0s;
} }
} }
.v-card { .v-card {
width: 100%; width: 100%;
overflow: visible; overflow: visible;
.color-inherits { .color-inherits {
color: inherit; color: inherit;
} }
.feature-card { .feature-card {
.v-responsive__content { .v-responsive__content {
height: 100%; height: 100%;
}
}
.v-timeline-divider__before,.v-timeline-divider__after {
background: rgba(var(--v-border-color), 1);
}
.v-card-text{
padding: 24px 24px;
}
.v-card-item{
padding: 24px 24px;
} }
}
.v-timeline-divider__before,
.v-timeline-divider__after {
background: rgba(var(--v-border-color), 1);
}
.v-card-text {
padding: 24px 24px;
}
.v-card-item {
padding: 24px 24px;
}
} }
// Theme cards // Theme cards
.cardBordered { .cardBordered {
.v-card { .v-card {
box-shadow: none !important; box-shadow: none !important;
border: 1px solid rgb(var(--v-theme-borderColor)); border: 1px solid rgb(var(--v-theme-borderColor));
} }
} }
.elevation-o-card{ .elevation-o-card {
.v-card-item{ .v-card-item {
padding: 0.625rem 1rem; padding: 0.625rem 1rem;
} }
} }
.card-title{ .card-title {
font-size: 18px; font-size: 18px;
font-weight: 500; font-weight: 500;
color:rgb(var(--v-theme-textPrimary)); color: rgb(var(--v-theme-textPrimary));
} }
.card-subtitle{ .card-subtitle {
font-size: 14px; font-size: 14px;
font-weight: 400; font-weight: 400;
color:rgb(var(--v-theme-textSecondary)); color: rgb(var(--v-theme-textSecondary));
}
.dark-card-title {
font-size: 18px;
font-weight: 500;
color: rgb(var(--v-theme-textPrimary));
} }
.dark-card-title{
font-size: 18px;
font-weight: 500;
color:rgb(var(--v-theme-textPrimary));
}
+2 -2
View File
@@ -1,3 +1,3 @@
.v-pagination__item--is-active .v-btn__overlay { .v-pagination__item--is-active .v-btn__overlay {
opacity: 0.15 !important; opacity: 0.15 !important;
} }
+69 -82
View File
@@ -1,99 +1,86 @@
.v-table { .v-table {
&.datatabels {
&.productlist {
.v-data-table-header__content span {
color: rgb(var(--v-theme-textPrimary));
}
&.datatabels { .v-toolbar {
.v-input__control {
&.productlist { max-width: 300px;
.v-data-table-header__content span {
color: rgb(var(--v-theme-textPrimary));
}
.v-toolbar {
.v-input__control {
max-width: 300px;
}
.v-toolbar__content {
height: auto !important;
}
}
thead tr th:first-child {
padding-left: 0px !important;
}
tbody tr td:first-child {
padding-left: 0px !important;
}
tbody tr td{
padding: 15px;
}
} }
.v-selection-control--dirty .v-selection-control__input>.v-icon { .v-toolbar__content {
color: rgb(var(--v-theme-primary)); height: auto !important;
} }
}
thead tr th:first-child {
padding-left: 0px !important;
}
tbody tr td:first-child {
padding-left: 0px !important;
}
tbody tr td {
padding: 15px;
}
} }
.v-selection-control--dirty .v-selection-control__input > .v-icon {
color: rgb(var(--v-theme-primary));
}
}
} }
@media screen and (max-width: 1368px) {
@media screen and (max-width:1368px) { .v-table {
&.datatabels {
.v-table { &.productlist {
.v-data-table-header__content span {
&.datatabels { color: rgb(var(--v-theme-textPrimary));
&.productlist {
.v-data-table-header__content span {
color: rgb(var(--v-theme-textPrimary));
}
table {
tbody {
tr {
td {
padding: 14px 5px !important;
&:first-child {
padding-left: 15px !important;
}
}
}
}
thead {
tr {
th {
padding: 14px 5px !important;
&:first-child {
padding-left: 15px !important;
}
}
}
}
}
}
} }
} table {
tbody {
tr {
td {
padding: 14px 5px !important;
&:first-child {
padding-left: 15px !important;
}
}
}
}
thead {
tr {
th {
padding: 14px 5px !important;
&:first-child {
padding-left: 15px !important;
}
}
}
}
}
}
}
}
} }
.v-pagination { .v-pagination {
.v-pagination__list { .v-pagination__list {
.v-pagination__item--is-active { .v-pagination__item--is-active {
.v-btn { .v-btn {
.v-btn__overlay { .v-btn__overlay {
opacity: 0; opacity: 0;
}
background-color: rgb(var(--v-theme-grey100)) !important;
}
} }
background-color: rgb(var(--v-theme-grey100)) !important;
}
} }
} }
}
@@ -1,6 +1,6 @@
.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 {
border-color: transparent !important; border-color: transparent !important;
} }
+8 -9
View File
@@ -2,17 +2,17 @@
.v-field--variant-outlined .v-field__outline__start.v-locale--is-ltr, .v-field--variant-outlined .v-field__outline__start.v-locale--is-ltr,
.v-locale--is-ltr .v-field--variant-outlined .v-field__outline__start { .v-locale--is-ltr .v-field--variant-outlined .v-field__outline__start {
border-radius: $border-radius-root 0 0 $border-radius-root; border-radius: $border-radius-root 0 0 $border-radius-root;
} }
.v-field--variant-outlined .v-field__outline__end.v-locale--is-ltr, .v-field--variant-outlined .v-field__outline__end.v-locale--is-ltr,
.v-locale--is-ltr .v-field--variant-outlined .v-field__outline__end { .v-locale--is-ltr .v-field--variant-outlined .v-field__outline__end {
border-radius: 0 $border-radius-root $border-radius-root 0; border-radius: 0 $border-radius-root $border-radius-root 0;
} }
.v-field { .v-field {
font-size: 14px; font-size: 14px;
color: rgba(var(--v-theme-textPrimary)); color: rgba(var(--v-theme-textPrimary));
} }
// select outlined // select outlined
@@ -20,10 +20,9 @@
.v-field--variant-outlined .v-field__outline__notch::before, .v-field--variant-outlined .v-field__outline__notch::before,
.v-field--variant-outlined .v-field__outline__notch::after, .v-field--variant-outlined .v-field__outline__notch::after,
.v-field--variant-outlined .v-field__outline__end { .v-field--variant-outlined .v-field__outline__end {
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)); }
}
+11 -11
View File
@@ -2,34 +2,34 @@
.v-input--density-default, .v-input--density-default,
.v-field--variant-solo, .v-field--variant-solo,
.v-field--variant-filled { .v-field--variant-filled {
--v-input-control-height: 51px; --v-input-control-height: 51px;
--v-input-padding-top: 14px; --v-input-padding-top: 14px;
} }
// comfortable // comfortable
.v-input--density-comfortable { .v-input--density-comfortable {
--v-input-control-height: 44px; --v-input-control-height: 44px;
} }
// compact // compact
.v-input--density-compact { .v-input--density-compact {
--v-input-padding-top: 10px; --v-input-padding-top: 10px;
} }
.v-label { .v-label {
font-size: 14px; font-size: 14px;
opacity: 0.7; opacity: 0.7;
font-weight: 500 !important; font-weight: 500 !important;
} }
.v-switch .v-label, .v-switch .v-label,
.v-checkbox .v-label { .v-checkbox .v-label {
opacity: 1; opacity: 1;
} }
.v-text-field__suffix { .v-text-field__suffix {
opacity: 1; opacity: 1;
padding-left: 20px; padding-left: 20px;
} }
.shadow-none .v-field--variant-solo { .shadow-none .v-field--variant-solo {
box-shadow: none !important; box-shadow: none !important;
} }
+13 -12
View File
@@ -1,18 +1,19 @@
.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{
border: 0 !important;
} }
.v-stepper-header,.v-stepper.v-sheet{ .v-time-picker-controls__ampm__btn.v-btn.v-btn--density-default {
box-shadow: none !important; border: 0 !important;
}
.v-stepper-header,
.v-stepper.v-sheet {
box-shadow: none !important;
} }
.v-time-picker-controls__time__btn.v-btn--density-default.v-btn { .v-time-picker-controls__time__btn.v-btn--density-default.v-btn {
width: 55px !important; width: 55px !important;
height: 55px !important; height: 55px !important;
font-size: 30px; font-size: 30px;
} }
.v-time-picker-controls__time__separator { .v-time-picker-controls__time__separator {
font-size: 36px !important; font-size: 36px !important;
} }
+27 -27
View File
@@ -1,34 +1,34 @@
.v-list.theme-list { .v-list.theme-list {
.v-list-item:hover > .v-list-item__overlay { .v-list-item:hover > .v-list-item__overlay {
opacity: 1; opacity: 1;
z-index: 1; z-index: 1;
} }
.v-list-item--variant-text { .v-list-item--variant-text {
.v-list-item__overlay {
background: rgb(var(--v-theme-hoverColor));
}
}
.v-list-item__prepend,
.v-list-item__content {
z-index: 2;
}
.v-list-item__overlay { .v-list-item__overlay {
background-color: rgb(var(--v-theme-hoverColor)); background: rgb(var(--v-theme-hoverColor));
} }
.v-list-item.v-list-item--active{ }
.v-list-item__overlay{
opacity: 1;
}
}
.mail-items{ .v-list-item__prepend,
min-height: 40px !important; .v-list-item__content {
margin-bottom: 5px !important; z-index: 2;
} }
.v-list-item__overlay {
background-color: rgb(var(--v-theme-hoverColor));
}
.v-list-item.v-list-item--active {
.v-list-item__overlay {
opacity: 1;
}
}
.mail-items {
min-height: 40px !important;
margin-bottom: 5px !important;
}
} }
.v-list-item-title{ .v-list-item-title {
font-size: 14px; font-size: 14px;
} }
@@ -1,6 +1,6 @@
// For checkbox & radios // For checkbox & radios
.v-selection-control__input > .v-icon.mdi-checkbox-blank-outline, .v-selection-control__input > .v-icon.mdi-checkbox-blank-outline,
.v-selection-control__input > .v-icon.mdi-radiobox-blank { .v-selection-control__input > .v-icon.mdi-radiobox-blank {
color: rgb(var(--v-theme-inputBorder)); color: rgb(var(--v-theme-inputBorder));
opacity: 1; opacity: 1;
} }
+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;
} }
} }
+6 -5
View File
@@ -1,8 +1,9 @@
.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,
background: rgb(var(--v-theme-primary)) !important; .v-stepper-item--complete .v-stepper-item__avatar.v-avatar {
background: rgb(var(--v-theme-primary)) !important;
} }
.v-stepper-item__avatar.v-avatar { .v-stepper-item__avatar.v-avatar {
background: rgba(var(--v-theme-primary), var(--v-medium-emphasis-opacity)) !important; background: rgba(var(--v-theme-primary), var(--v-medium-emphasis-opacity)) !important;
color: rgb(var(--v-theme-on-primary)) !important; color: rgb(var(--v-theme-on-primary)) !important;
} }
+65 -77
View File
@@ -1,99 +1,87 @@
.v-table .v-table__wrapper > table > tbody > tr:not(:last-child) > td, .v-table .v-table__wrapper > table > tbody > tr:not(:last-child) > td,
.v-table .v-table__wrapper > table > tbody > tr:not(:last-child) > th, .v-table .v-table__wrapper > table > tbody > tr:not(:last-child) > th,
.v-table .v-table__wrapper > table > thead > tr:last-child > th { .v-table .v-table__wrapper > table > thead > tr:last-child > th {
border-bottom: thin solid rgba(var(--v-border-color)) !important; border-bottom: thin solid rgba(var(--v-border-color)) !important;
} }
.v-data-table{ .v-data-table {
th.v-data-table__th{ th.v-data-table__th {
font-size:16px; font-size: 16px;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
} }
td.v-data-table__td{ td.v-data-table__td {
font-size: 14px; font-size: 14px;
text-wrap: nowrap; text-wrap: nowrap;
} }
.v-data-table-footer{ .v-data-table-footer {
padding: 15px 8px; padding: 15px 8px;
} }
.v-data-table-header__sort-badge{ .v-data-table-header__sort-badge {
background-color:rgb(var(--v-theme-borderColor)) !important; background-color: rgb(var(--v-theme-borderColor)) !important;
} }
.tdhead{ .tdhead {
font-size:16px; font-size: 16px;
} }
} }
@media screen and (max-width:767px) { @media screen and (max-width: 767px) {
.v-data-table-footer{ .v-data-table-footer {
justify-content: center; justify-content: center;
} }
} }
.v-table { .v-table {
&.ticket-table {
table {
thead {
th {
font-weight: 600 !important;
}
}
tbody {
tr {
td {
padding: 16px 16px !important;
}
}
}
}
}
&.ticket-table { &.invoice-table {
.v-table__wrapper {
table {
thead {
th {
font-weight: 600 !important;
padding: 0px 24px !important;
table { &:first-child {
thead { padding-left: 0 !important;
th {
font-weight: 600 !important;
}
} }
tbody { &:last-child {
tr { padding-right: 0 !important;
td {
padding: 16px 16px !important;
}
}
} }
}
} }
} tbody {
tr {
td {
padding: 8px 24px !important;
&.invoice-table { &:first-child {
.v-table__wrapper { padding-left: 0 !important;
table { }
thead {
th {
font-weight: 600 !important;
padding: 0px 24px !important;
&:first-child { &:last-child {
padding-left: 0 !important; padding-right: 0 !important;
} }
&:last-child {
padding-right: 0 !important;
}
}
}
tbody {
tr {
td {
padding: 8px 24px !important;
&:first-child {
padding-left: 0 !important;
}
&:last-child {
padding-right: 0 !important;
}
}
}
}
} }
}
} }
}
} }
}
}
}
+9 -10
View File
@@ -1,14 +1,13 @@
@use "../variables" as *; @use "../variables" as *;
.theme-tab { .theme-tab {
&.v-tabs { &.v-tabs {
.v-tab { .v-tab {
border-radius: $border-radius-root !important; border-radius: $border-radius-root !important;
min-width: auto !important; min-width: auto !important;
&.v-slide-group-item--active { &.v-slide-group-item--active {
background: rgb(var(--v-theme-primary)); background: rgb(var(--v-theme-primary));
}
}
}
} }
} }
}
-1
View File
@@ -10,4 +10,3 @@
background-color: rgba(0, 0, 0, 0.025); background-color: rgba(0, 0, 0, 0.025);
} }
} }
File diff suppressed because it is too large Load Diff
+74 -79
View File
@@ -1,104 +1,99 @@
@use "../variables" as *; @use "../variables" as *;
.front-lp-header { .front-lp-header {
.v-toolbar {
background: rgb(var(--v-theme-surface));
}
.v-toolbar{ &.v-app-bar .v-toolbar__content {
background: rgb(var(--v-theme-surface)); padding: 0;
} }
&.v-app-bar .v-toolbar__content {
padding: 0;
} .v-toolbar__content {
background: transparent !important;
box-shadow: none !important;
}
.v-toolbar__content { &.v-toolbar {
background: transparent !important; background: transparent !important;
box-shadow: none !important; top: 0 !important;
} }
&.v-toolbar { .v-toolbar {
background: transparent !important; background: transparent !important;
top: 0 !important; }
}
.v-toolbar {
background: transparent !important;
}
&.sticky-header {
position: fixed !important;
top: 0 !important;
transition: 0.5s;
background-color: rgba(var(--v-theme-surface)) !important;
box-shadow: 0 4px 29px -11px #3a4b7424 !important;
}
&.sticky-header {
position: fixed !important;
top: 0 !important;
transition: 0.5s;
background-color: rgba(var(--v-theme-surface)) !important;
box-shadow: 0 4px 29px -11px #3a4b7424 !important;
}
} }
// //
// mega menu // mega menu
// //
.white-btn{ .white-btn {
background-color: #769CFF; background-color: #769cff;
} }
.front_wrapper { .front_wrapper {
&.v-menu .v-overlay__content {
&.v-menu .v-overlay__content { margin: 0 auto;
margin: 0 auto; left: 0 !important;
left: 0 !important; right: 0;
right: 0; }
.megamenu {
&::before {
content: "";
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 96%;
background-color: rgba(55, 114, 255, 0.2);
border-radius: 7px;
opacity: 0;
} }
.megamenu { .v-btn {
&::before { top: 50%;
content: ''; transform: translateY(-50%);
position: absolute; z-index: 1;
top: 0px; left: 0;
left: 0px; right: 0;
width: 100%; min-width: 100px;
height: 96%; opacity: 0;
background-color: rgba(55, 114, 255, 0.2); font-size: 13px;
border-radius: 7px;
opacity: 0;
}
.v-btn {
top: 50%;
transform: translateY(-50%);
z-index: 1;
left: 0;
right: 0;
min-width: 100px;
opacity: 0;
font-size: 13px;
}
&:hover {
&::before,
.v-btn {
opacity: 1;
}
}
} }
&:hover {
&::before,
.v-btn {
opacity: 1;
}
}
}
} }
.lp-drawer { .lp-drawer {
&.v-navigation-drawer { &.v-navigation-drawer {
top: 0 !important; top: 0 !important;
height: 100% !important; height: 100% !important;
z-index: 1007 !important; z-index: 1007 !important;
} }
} }
.lp-mobile-sidebar { .lp-mobile-sidebar {
.v-list { .v-list {
.v-list-item__content { .v-list-item__content {
overflow: inherit; overflow: inherit;
}
}
.v-list-group__items .v-list-item {
padding-inline-start: 25px !important;
} }
}
.v-list-group__items .v-list-item {
padding-inline-start: 25px !important;
}
} }
.v-btn--size-default { .v-btn--size-default {
&.nav-links { &.nav-links {
font-size: $font-size-root !important; font-size: $font-size-root !important;
} }
} }
+20 -20
View File
@@ -1,40 +1,40 @@
html { html {
overflow-y: auto; overflow-y: auto;
} }
.v-main{ .v-main {
background:rgb(var(--v-theme-background)) !important; ; background: rgb(var(--v-theme-background)) !important;
} }
@media (max-width: 1279px) { @media (max-width: 1279px) {
.v-main { .v-main {
margin: 0 10px; margin: 0 10px;
} }
} }
.cursor-pointer { .cursor-pointer {
cursor: pointer; cursor: pointer;
} }
.page-wrapper { .page-wrapper {
min-height: calc(100vh - 100px); min-height: calc(100vh - 100px);
padding: 24px; padding: 24px;
// border-radius: $border-radius-root; // border-radius: $border-radius-root;
@media screen and (max-width: 767px) { @media screen and (max-width: 767px) {
padding: 20px 10px; padding: 20px 10px;
} }
} }
.maxWidth { .maxWidth {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
.fixed-width { .fixed-width {
max-width: 1300px; max-width: 1300px;
} }
.right-pos-img { .right-pos-img {
position: absolute; position: absolute;
right: 0; right: 0;
top: 0; top: 0;
height: 100%; height: 100%;
} }
+65 -56
View File
@@ -1,93 +1,102 @@
.v-btn.customizer-btn { .v-btn.customizer-btn {
position: fixed; position: fixed;
bottom: 30px; bottom: 30px;
right: 30px; right: 30px;
border-radius: 50%; border-radius: 50%;
// .icon-tabler-settings { // .icon-tabler-settings {
// animation: progress-circular-rotate 1.4s linear infinite; // animation: progress-circular-rotate 1.4s linear infinite;
// transform-origin: center center; // transform-origin: center center;
// transition: all 0.2s ease-in-out; // transition: all 0.2s ease-in-out;
// } // }
} }
.btn-group-custom { .btn-group-custom {
&.v-btn-group { &.v-btn-group {
height: 66px !important; height: 66px !important;
overflow: unset !important; overflow: unset !important;
.v-btn { .v-btn {
height: 66px !important; height: 66px !important;
padding: 0 20px; padding: 0 20px;
border: 1px solid rgb(var(--v-theme-borderColor), 0.7) !important; border: 1px solid rgb(var(--v-theme-borderColor), 0.7) !important;
transition: all 0.1s ease-in 0s; transition: all 0.1s ease-in 0s;
&:hover { &:hover {
transform: scale(1.05); transform: scale(1.05);
} }
&.text-primary { &.text-primary {
.v-btn__overlay { .v-btn__overlay {
background: transparent !important; background: transparent !important;
}
.icon {
color: rgb(var(--v-theme-primary)) !important;
fill: rgb(var(--v-theme-primary), 0.2);
}
color: rgb(var(--v-theme-primary)) !important;
}
} }
.icon {
color: rgb(var(--v-theme-primary)) !important;
fill: rgb(var(--v-theme-primary), 0.2);
}
color: rgb(var(--v-theme-primary)) !important;
}
} }
}
} }
.hover-btns { .hover-btns {
transition: all 0.1s ease-in 0s; transition: all 0.1s ease-in 0s;
&:hover { &:hover {
transform: scale(1.05); transform: scale(1.05);
} }
} }
// all theme colors // all theme colors
.v-avatar.themeBlue, .v-avatar.themeBlue,
.v-avatar.themeDarkBlue { .v-avatar.themeDarkBlue {
background: #1e88e5; background: #1e88e5;
} }
.v-avatar.themeAqua, .v-avatar.themeAqua,
.v-avatar.themeDarkAqua { .v-avatar.themeDarkAqua {
background: #0074ba; background: #0074ba;
} }
.v-avatar.themePurple, .v-avatar.themePurple,
.v-avatar.themeDarkPurple { .v-avatar.themeDarkPurple {
background: #763ebd; background: #763ebd;
} }
.v-avatar.themeGreen, .v-avatar.themeGreen,
.v-avatar.themeDarkGreen { .v-avatar.themeDarkGreen {
background: #0a7ea4; background: #0a7ea4;
} }
.v-avatar.themeCyan, .v-avatar.themeCyan,
.v-avatar.themeDarkCyan { .v-avatar.themeDarkCyan {
background: #01c0c8; background: #01c0c8;
} }
.v-avatar.themeOrange, .v-avatar.themeOrange,
.v-avatar.themeDarkOrange { .v-avatar.themeDarkOrange {
background: #fa896b; background: #fa896b;
} }
.DARK_BLUE_THEME,
.DARK_AQUA_THEME,
.DARK_ORANGE_THEME,
.DARK_PURPLE_THEME,
.DARK_GREEN_THEME,
.DARK_CYAN_THEME {
.togglethemeBlue {
display: block !important;
}
.DARK_BLUE_THEME, .DARK_AQUA_THEME, .DARK_ORANGE_THEME, .DARK_PURPLE_THEME, .DARK_GREEN_THEME, .DARK_CYAN_THEME { .togglethemeDarkBlue {
.togglethemeBlue { display: none !important;
display: block !important; }
}
.togglethemeDarkBlue {
display: none !important;
}
} }
.BLUE_THEME, .AQUA_THEME, .ORANGE_THEME, .PURPLE_THEME, .GREEN_THEME, .CYAN_THEME { .BLUE_THEME,
.togglethemeDarkBlue { .AQUA_THEME,
display: block !important; .ORANGE_THEME,
} .PURPLE_THEME,
.GREEN_THEME,
.CYAN_THEME {
.togglethemeDarkBlue {
display: block !important;
}
.togglethemeBlue { .togglethemeBlue {
display: none !important; display: none !important;
} }
} }
+43 -41
View File
@@ -1,109 +1,111 @@
.h-100 { .h-100 {
height: 100%; height: 100%;
} }
.w-100 { .w-100 {
width: 100%; width: 100%;
} }
.h-100vh { .h-100vh {
height: 100vh; height: 100vh;
} }
.gap-2 { .gap-2 {
gap: 8px; gap: 8px;
} }
.gap-3 { .gap-3 {
gap: 16px; gap: 16px;
} }
.gap-4 { .gap-4 {
gap: 24px; gap: 24px;
} }
.text-white { .text-white {
color: rgb(255, 255, 255) !important; color: rgb(255, 255, 255) !important;
} }
// border // border
.border-bottom { .border-bottom {
border-bottom: 1px solid rgba(0, 0, 0, .05); border-bottom: 1px solid rgba(0, 0, 0, 0.05);
} }
.opacity-1 { .opacity-1 {
opacity: 1 !important; opacity: 1 !important;
} }
.opacity-50 { .opacity-50 {
opacity: 0.5; opacity: 0.5;
} }
.z-auto.v-card { .z-auto.v-card {
z-index: auto; z-index: auto;
} }
.obj-cover { .obj-cover {
object-fit: cover; object-fit: cover;
} }
.cursor-move { .cursor-move {
cursor: move; cursor: move;
} }
body { body {
cursor: default; cursor: default;
} }
input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="file"]):not([type="range"]):not([type="color"]), input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not(
[type="reset"]
):not([type="file"]):not([type="range"]):not([type="color"]),
textarea, textarea,
[contenteditable="true"] { [contenteditable="true"] {
cursor: text; cursor: text;
} }
//Date time picker //Date time picker
input[type="date"], input[type="date"],
input[type="time"] { input[type="time"] {
display: block !important; display: block !important;
} }
input[type="date"]::-webkit-calendar-picker-indicator, input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator { input[type="time"]::-webkit-calendar-picker-indicator {
display: block !important; display: block !important;
} }
.ProseMirror { .ProseMirror {
min-height: 150px; min-height: 150px;
} }
.upload-btn-wrapper { .upload-btn-wrapper {
width: 150px; width: 150px;
height: 140px; height: 140px;
margin: 0 auto; margin: 0 auto;
box-shadow: 0 0.5rem 1.5rem 0.5rem rgba(0, 0, 0, 0.075); box-shadow: 0 0.5rem 1.5rem 0.5rem rgba(0, 0, 0, 0.075);
input[type=file] { input[type="file"] {
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
opacity: 0; opacity: 0;
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
} }
.bg-transparent { .bg-transparent {
background-color: transparent !important; background-color: transparent !important;
} }
.bg-dark{ .bg-dark {
background-color: rgba(0, 0, 0, .08); background-color: rgba(0, 0, 0, 0.08);
} }
.bg-white-opacity{ .bg-white-opacity {
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
} }
@media screen and (max-width:1368px) and (min-width:1200px) { @media screen and (max-width: 1368px) and (min-width: 1200px) {
.space-20 { .space-20 {
padding: 30px 20px !important; padding: 30px 20px !important;
} }
} }
+314 -321
View File
@@ -1,341 +1,334 @@
.v-locale--is-rtl { .v-locale--is-rtl {
.customizer-btn { .customizer-btn {
left: 30px; left: 30px;
right: unset; right: unset;
}
.horizontal-navbar .icon-box {
margin-left: 12px;
}
.bg-img-1 {
position: absolute;
bottom: 0;
left: 0;
right: unset !important;
transform: scaleX(-1);
}
.ml-1 {
margin-left: unset !important;
margin-right: 4px;
}
.ml-2 {
margin-left: unset !important;
margin-right: 8px;
}
.mr-1 {
margin-right: unset !important;
margin-left: 4px;
}
.mr-2 {
margin-right: unset !important;
margin-left: 8px;
}
.mr-sm-2 {
margin-right: unset !important;
margin-left: 8px;
}
.mr-3 {
margin-right: unset !important;
margin-left: 12px !important;
}
.mr-4 {
margin-right: unset !important;
margin-left: 16px !important;
}
.ml-3 {
margin-left: unset !important;
margin-right: 12px !important;
}
.mr-auto {
margin-left: auto !important;
margin-right: unset !important;
}
.ml-4 {
margin-left: unset !important;
margin-right: 16px;
}
.ml-sm-4 {
margin-left: unset !important;
margin-right: 16px;
}
.ml-md-4 {
margin-left: unset !important;
margin-right: 16px;
}
.ml-sm-3 {
margin-left: unset !important;
margin-right: 12px;
}
.ml-5 {
margin-left: unset !important;
margin-right: 20px;
}
.ml-6 {
margin-left: unset !important;
margin-right: 24px;
}
.ml-10 {
margin-left: unset !important;
margin-right: 40px;
}
.pl-1 {
padding-left: unset !important;
padding-right: 4px !important;
}
.pl-2 {
padding-left: unset !important;
padding-right: 8px !important;
}
.pr-2 {
padding-left: 8px !important;
}
.pr-4 {
padding-left: 16px !important;
padding-right: unset !important;
}
.pl-4 {
padding-left: unset !important;
padding-right: 16px !important;
}
.right-pos-img {
right: unset;
left: 0;
transform: scaleX(-1);
top: 0;
}
.badg-dotDetail {
left: 0;
right: -8px;
}
.text-right {
text-align: left !important;
}
.text-sm-right,
.text-md-right {
text-align: left !important;
}
.text-sm-left {
text-align: right !important;
}
.text-left {
text-align: right !important;
}
.ml-auto,
.ml-sm-auto {
margin-left: unset !important;
margin-right: auto !important;
}
.justify-start {
justify-content: flex-end !important;
}
.vertical-table .v-table > .v-table__wrapper > table > tbody > tr > th {
border-left: thin solid rgba(var(--v-border-color), 1) !important;
}
.authentication .auth-header {
left: unset;
right: 0;
}
.horizontal-navbar li a {
padding: 10px 13px;
}
.horizontal-navbar {
li {
margin-right: 0;
margin-left: 15px;
}
}
// &.v-menu .v-overlay__content,
// &.search_popup .v-overlay__content,
// &.language_dropdown .v-overlay__content,
// &.notification_popup .v-overlay__content,
// &.profile_popup .v-overlay__content {
// left: inherit;
// }
.related-Product {
.carousel__prev.navarrow {
top: 0;
right: unset !important;
left: 0;
} }
.horizontal-navbar .icon-box { .carousel__next.navarrow {
margin-left: 12px; top: 0;
right: unset !important;
left: 45px;
}
}
//RTL mode minisidebar hover to active scrollbar
.ps--active-y > .ps__rail-y {
right: unset !important;
left: 0;
}
//RTL mode sidebar scrollbar on right side
.left-customizer {
.ps__rail-y {
right: 0 !important;
}
}
.horizontal-navbar .ddMenu {
padding: 10px 15px 10px 0px;
}
.v-list-group__items {
.iconClass {
position: relative;
left: unset;
right: -2px;
}
}
@media (min-width: 960px) {
.horizontal-navbar .ddLevel-2,
.horizontal-navbar .ddLevel-3 {
top: -5px;
right: 212px;
} }
.horizontal-navbar li a .navIcon {
margin-right: 0;
margin-left: 10px;
}
}
.leftSidebar .profile-name h5 {
direction: ltr;
}
.bg-img-1 { .horizontal-navbar {
position: absolute; .ddMenu {
bottom: 0; .navItemLink {
left: 0; padding-right: 15px;
right: unset !important; }
transform: scaleX(-1); }
}
@media screen and (max-width: 1279px) {
.mini-sidebar {
.v-navigation-drawer.v-navigation-drawer--right {
width: 270px !important;
}
.v-navigation-drawer.v-navigation-drawer--left {
width: 320px !important;
}
}
}
.rtlImg {
transform: scaleX(-1);
}
.marquee1-group {
animation: marquee-rtl 45s linear infinite;
}
.marquee2-group {
animation: marquee2-rtl 45s linear infinite;
}
@keyframes marquee-rtl {
0% {
transform: translate3d(0, 0, 0);
} }
.ml-1 { 100% {
margin-left: unset !important; transform: translate3d(2086px, 0, 0);
margin-right: 4px; }
}
@keyframes marquee2-rtl {
0% {
transform: translate3d(2086px, 0, 0);
} }
.ml-2 { 100% {
margin-left: unset !important; transform: translate3d(0, 0, 0);
margin-right: 8px;
} }
}
.mr-1 { .front-wraper {
margin-right: unset !important; .testimonials {
margin-left: 4px; .slide-counter {
} left: -50px;
}
.mr-2 { .carousel__prev {
margin-right: unset !important; right: -74%;
margin-left: 8px;
}
.mr-sm-2 {
margin-right: unset !important;
margin-left: 8px;
}
.mr-3 {
margin-right: unset !important;
margin-left: 12px !important;
}
.mr-4 {
margin-right: unset !important;
margin-left: 16px !important;
}
.ml-3 {
margin-left: unset !important;
margin-right: 12px !important;
}
.mr-auto {
margin-left: auto !important;
margin-right: unset !important;
}
.ml-4 {
margin-left: unset !important;
margin-right: 16px;
}
.ml-sm-4 {
margin-left: unset !important;
margin-right: 16px;
}
.ml-md-4 {
margin-left: unset !important;
margin-right: 16px;
}
.ml-sm-3 {
margin-left: unset !important;
margin-right: 12px;
}
.ml-5 {
margin-left: unset !important;
margin-right: 20px;
}
.ml-6 {
margin-left: unset !important;
margin-right: 24px;
}
.ml-10 {
margin-left: unset !important;
margin-right: 40px;
}
.pl-1 {
padding-left: unset !important;
padding-right: 4px !important;
}
.pl-2 {
padding-left: unset !important;
padding-right: 8px !important;
}
.pr-2 {
padding-left: 8px !important;
}
.pr-4 {
padding-left: 16px !important;
padding-right: unset !important;
}
.pl-4 {
padding-left: unset !important;
padding-right: 16px !important;
}
.right-pos-img {
right: unset;
left: 0;
transform: scaleX(-1);
top: 0;
}
.badg-dotDetail {
left: 0;
right: -8px;
}
.text-right {
text-align: left !important;
}
.text-sm-right,
.text-md-right {
text-align: left !important;
}
.text-sm-left {
text-align: right !important;
}
.text-left {
text-align: right !important;
}
.ml-auto,
.ml-sm-auto {
margin-left: unset !important;
margin-right: auto !important;
}
.justify-start {
justify-content: flex-end !important;
}
.vertical-table .v-table>.v-table__wrapper>table>tbody>tr>th {
border-left: thin solid rgba(var(--v-border-color), 1) !important;
}
.authentication .auth-header {
left: unset; left: unset;
right: 0;
}
.horizontal-navbar li a { .rtlnav {
padding: 10px 13px; transform: scaleX(-1);
}
.horizontal-navbar {
li {
margin-right: 0;
margin-left: 15px;
} }
} }
// &.v-menu .v-overlay__content, .carousel__next {
// &.search_popup .v-overlay__content, right: -60%;
// &.language_dropdown .v-overlay__content,
// &.notification_popup .v-overlay__content,
// &.profile_popup .v-overlay__content {
// left: inherit;
// }
.related-Product {
.carousel__prev.navarrow {
top: 0;
right: unset !important;
left: 0;
}
.carousel__next.navarrow {
top: 0;
right: unset !important;
left: 45px;
}
}
//RTL mode minisidebar hover to active scrollbar
.ps--active-y>.ps__rail-y {
right: unset !important;
left: 0;
}
//RTL mode sidebar scrollbar on right side
.left-customizer {
.ps__rail-y {
right: 0 !important;
}
}
.horizontal-navbar .ddMenu {
padding: 10px 15px 10px 0px;
}
.v-list-group__items {
.iconClass {
position: relative;
left: unset;
right: -2px;
}
}
@media (min-width: 960px) {
.horizontal-navbar .ddLevel-2,
.horizontal-navbar .ddLevel-3 {
top: -5px;
right: 212px;
}
.horizontal-navbar li a .navIcon {
margin-right: 0;
margin-left: 10px;
}
}
.leftSidebar .profile-name h5 {
direction: ltr;
}
.horizontal-navbar {
.ddMenu {
.navItemLink {
padding-right: 15px;
}
}
}
@media screen and (max-width:1279px) {
.mini-sidebar {
.v-navigation-drawer.v-navigation-drawer--right {
width: 270px !important;
}
.v-navigation-drawer.v-navigation-drawer--left {
width: 320px !important;
}
}
}
.rtlImg {
transform: scaleX(-1);
}
.marquee1-group {
animation: marquee-rtl 45s linear infinite;
}
.marquee2-group {
animation: marquee2-rtl 45s linear infinite;
}
@keyframes marquee-rtl {
0% {
transform: translate3d(0, 0, 0);
}
100% {
transform: translate3d(2086px, 0, 0);
}
}
@keyframes marquee2-rtl {
0% {
transform: translate3d(2086px, 0, 0);
}
100% {
transform: translate3d(0, 0, 0);
}
}
.front-wraper {
.testimonials {
.slide-counter {
left: -50px;
}
.carousel__prev {
right: -74%;
left: unset;
.rtlnav {
transform: scaleX(-1);
}
}
.carousel__next {
right: -60%;
left: unset;
.rtlnav {
transform: scaleX(-1);
}
}
}
}
//For Rtl Chart
.rtl-me-n7 {
margin-inline-start: -28px !important;
margin-inline-end: unset !important;
}
.profile-img::before {
left: unset; left: unset;
right: 14px;
}
} .rtlnav {
transform: scaleX(-1);
}
}
}
}
//For Rtl Chart
.rtl-me-n7 {
margin-inline-start: -28px !important;
margin-inline-end: unset !important;
}
.profile-img::before {
left: unset;
right: 14px;
}
}
+61 -61
View File
@@ -1,98 +1,98 @@
$sizes: ( $sizes: (
'display-1': 44px, "display-1": 44px,
'display-2': 40px, "display-2": 40px,
'display-3': 30px, "display-3": 30px,
'h1': 36px, "h1": 36px,
'h2': 30px, "h2": 30px,
'h3': 21px, "h3": 21px,
'h4': 18px, "h4": 18px,
'h5': 16px, "h5": 16px,
'h6': 14px, "h6": 14px,
'text-10': 10px, "text-10": 10px,
'text-12': 12px, "text-12": 12px,
'text-13': 13px, "text-13": 13px,
'text-14': 14px, "text-14": 14px,
'text-15': 15px, "text-15": 15px,
'text-16': 16px, "text-16": 16px,
'text-17': 17px, "text-17": 17px,
'text-18': 18px, "text-18": 18px,
'text-20': 20px, "text-20": 20px,
'text-22': 22px, "text-22": 22px,
'text-24': 24px, "text-24": 24px,
'text-28': 28px, "text-28": 28px,
'text-34': 34px, "text-34": 34px,
'text-40': 40px, "text-40": 40px,
'text-44': 44px, "text-44": 44px,
'text-48': 48px, "text-48": 48px,
'text-50': 50px, "text-50": 50px,
'text-52': 52px, "text-52": 52px,
'text-56': 56px, "text-56": 56px,
'text-64': 64px, "text-64": 64px,
'body-text-1': 10px "body-text-1": 10px,
); );
@each $pixel, $size in $sizes { @each $pixel, $size in $sizes {
.#{$pixel} { .#{$pixel} {
font-size: $size; font-size: $size;
line-height: $size + 10; line-height: $size + 10;
} }
} }
$height: ( $height: (
'h-10': 10px, "h-10": 10px,
'h-12': 12px, "h-12": 12px,
'h-15': 15px, "h-15": 15px,
); );
@each $pixel, $size in $height { @each $pixel, $size in $height {
.#{$pixel} { .#{$pixel} {
height: $size; height: $size;
width: $size ; width: $size;
} }
} }
.textSecondary { .textSecondary {
color: rgb(var(--v-theme-textSecondary)) !important; color: rgb(var(--v-theme-textSecondary)) !important;
} }
.textPrimary { .textPrimary {
color: rgb(var(--v-theme-textPrimary)) !important; color: rgb(var(--v-theme-textPrimary)) !important;
} }
// line height // line height
.lh-md { .lh-md {
line-height: 1.57; line-height: 1.57;
} }
.lh-normal{ .lh-normal {
line-height: normal; line-height: normal;
} }
.font-weight-semibold { .font-weight-semibold {
font-weight: 600; font-weight: 600;
} }
// hover text // hover text
.text-hover-primary { .text-hover-primary {
color: rgb(var(--v-theme-textPrimary)); color: rgb(var(--v-theme-textPrimary));
&:hover { &:hover {
color: rgb(var(--v-theme-primary)); color: rgb(var(--v-theme-primary));
} }
} }
.link { .link {
color: rgb(var(--v-theme-textSecondary)); color: rgb(var(--v-theme-textSecondary));
text-decoration: none; text-decoration: none;
&:hover { &:hover {
color: rgb(var(--v-theme-primary)); color: rgb(var(--v-theme-primary));
} }
} }
.hover-primary { .hover-primary {
&:hover { &:hover {
color: rgb(var(--v-theme-primary)) !important; color: rgb(var(--v-theme-primary)) !important;
opacity: 1; opacity: 1;
} }
} }
+92 -93
View File
@@ -1,110 +1,109 @@
.v-app-bar { .v-app-bar {
.v-toolbar__content { .v-toolbar__content {
padding: 0 15px; padding: 0 15px;
> .v-btn:first-child { > .v-btn:first-child {
margin-inline-start: 0; margin-inline-start: 0;
}
.v-btn {
color: rgba(var(--v-theme-textsurface)) !important;
}
} }
// .v-btn {
// color: rgba(var(--v-theme-textsurface)) !important;
// }
}
} }
.custom-text-primary { .custom-text-primary {
&.v-list-item:hover > .v-list-item__overlay { &.v-list-item:hover > .v-list-item__overlay {
display: none; display: none;
} }
.custom-title {
color: rgb(var(--v-theme-textPrimary)) !important;
}
&:hover {
.custom-title { .custom-title {
color: rgb(var(--v-theme-textPrimary)) !important; color: rgb(var(--v-theme-primary)) !important;
}
&:hover {
.custom-title {
color: rgb(var(--v-theme-primary)) !important;
}
} }
}
} }
@media screen and (max-width:1279px) { @media screen and (max-width: 1279px) {
.mini-sidebar { .mini-sidebar {
.v-navigation-drawer.v-navigation-drawer--left { .v-navigation-drawer.v-navigation-drawer--left {
width: 270px !important; width: 270px !important;
}
} }
}
} }
.notify { .notify {
position: relative; position: relative;
top: -20px; top: -20px;
right: -8px; right: -8px;
.heartbit { .heartbit {
position: absolute; position: absolute;
top: -5px; top: -5px;
right: -2px; right: -2px;
height: 18px; height: 18px;
width: 18px; width: 18px;
z-index: 10; z-index: 10;
border: 2px solid rgb(var(--v-theme-error)); border: 2px solid rgb(var(--v-theme-error));
border-radius: 70px; border-radius: 70px;
animation: heartbit 1s ease-out; animation: heartbit 1s ease-out;
-moz-animation: heartbit 1s ease-out; -moz-animation: heartbit 1s ease-out;
-moz-animation-iteration-count: infinite; -moz-animation-iteration-count: infinite;
-o-animation: heartbit 1s ease-out; -o-animation: heartbit 1s ease-out;
-o-animation-iteration-count: infinite; -o-animation-iteration-count: infinite;
-webkit-animation: heartbit 1s ease-out; -webkit-animation: heartbit 1s ease-out;
-webkit-animation-iteration-count: infinite; -webkit-animation-iteration-count: infinite;
animation-iteration-count: infinite; animation-iteration-count: infinite;
}
.point {
width: 4px;
height: 4px;
border-radius: 30px;
position: absolute;
right: 5px;
top: 2px;
background-color: rgb(var(--v-theme-error));
position: absolute;
}
}
@keyframes heartbit {
0% {
transform: scale(0);
opacity: 0;
}
25% {
transform: scale(0.1);
opacity: 0.1;
}
50% {
transform: scale(0.5);
opacity: 0.3;
}
75% {
transform: scale(0.8);
opacity: 0.5;
}
100% {
transform: scale(1);
opacity: 0;
}
} }
.v-menu.mobile_popup .v-overlay__content { .point {
width: 100%; width: 4px;
height: 4px;
border-radius: 30px;
position: absolute;
right: 5px;
top: 2px;
background-color: rgb(var(--v-theme-error));
position: absolute;
}
} }
@media (max-width: 1199px) { @keyframes heartbit {
.main-head{ 0% {
&.v-app-bar .v-toolbar__content{ transform: scale(0);
width: 100%; opacity: 0;
justify-content: space-between; }
padding: 0 10px;
} 25% {
transform: scale(0.1);
opacity: 0.1;
}
50% {
transform: scale(0.5);
opacity: 0.3;
}
75% {
transform: scale(0.8);
opacity: 0.5;
}
100% {
transform: scale(1);
opacity: 0;
}
}
.v-menu.mobile_popup .v-overlay__content {
width: 100%;
}
@media (max-width: 1199px) {
.main-head {
&.v-app-bar .v-toolbar__content {
width: 100%;
justify-content: space-between;
padding: 0 10px;
} }
} }
}
+145 -156
View File
@@ -1,90 +1,84 @@
.month-table { .month-table {
&.custom-px-0 { &.custom-px-0 {
thead { thead {
tr { tr {
th:first-child { th:first-child {
padding-left: 0 !important; padding-left: 0 !important;
}
th:last-child {
padding-right: 0 !important;
}
}
} }
tr.month-item { th:last-child {
td:first-child { padding-right: 0 !important;
padding-left: 0 !important;
}
td:last-child {
padding-right: 0 !important;
}
} }
}
} }
tr.month-item { tr.month-item {
td { td:first-child {
padding-top: 16px !important; padding-left: 0 !important;
padding-bottom: 16px !important; }
}
&:hover { td:last-child {
background: transparent !important; padding-right: 0 !important;
} }
}
}
tr.month-item {
td {
padding-top: 16px !important;
padding-bottom: 16px !important;
} }
tr.month-item-0 { &:hover {
td { background: transparent !important;
padding-top: 12px !important; }
padding-bottom: 12px !important; }
}
&:hover { tr.month-item-0 {
background: transparent !important; td {
} padding-top: 12px !important;
padding-bottom: 12px !important;
} }
tr.month-item-hover { &:hover {
background: transparent !important;
}
}
td { tr.month-item-hover {
padding-top: 12px !important; td {
padding-bottom: 12px !important; padding-top: 12px !important;
} padding-bottom: 12px !important;
border-left: 4px solid transparent;
&:hover {
border-left: 4px solid rgba(var(--v-theme-primary)) !important;
}
} }
border-left: 4px solid transparent;
&:hover {
border-left: 4px solid rgba(var(--v-theme-primary)) !important;
}
}
} }
.no-line { .no-line {
.v-table .v-table__wrapper > table > tbody > tr:not(:last-child) > td {
border-bottom: 0 !important;
}
.v-table .v-table__wrapper>table>tbody>tr:not(:last-child)>td { .v-table .v-table__wrapper > table > tbody > tr > td {
border-bottom: 0 !important; padding: 12px;
} }
.v-table .v-table__wrapper>table>tbody>tr>td {
padding: 12px;
}
} }
.recent-transaction { .recent-transaction {
.line { .line {
width: 2px; width: 2px;
height: 35px; height: 35px;
} }
} }
.chip-label { .chip-label {
width: 80px; width: 80px;
justify-content: center; justify-content: center;
} }
// //
@@ -92,136 +86,131 @@
// //
body { body {
.apexcharts-tooltip { .apexcharts-tooltip {
border-radius: 16px; border-radius: 16px;
} }
.apexcharts-tooltip-marker { .apexcharts-tooltip-marker {
border-radius: 4px; border-radius: 4px;
width: 12px; width: 12px;
height: 4px; height: 4px;
} }
.apexcharts-tooltip.apexcharts-theme-dark { .apexcharts-tooltip.apexcharts-theme-dark {
background: rgba(17, 28, 45, 0.8); background: rgba(17, 28, 45, 0.8);
.apexcharts-tooltip-title {
border-bottom: 0;
background: rgba(17, 28, 45, 0.7);
}
}
.apexcharts-tooltip-series-group {
padding: 0 14px;
}
.apexcharts-tooltip-title { .apexcharts-tooltip-title {
padding: 10px 14px; border-bottom: 0;
background: rgba(17, 28, 45, 0.7);
} }
}
.apexcharts-tooltip-series-group {
padding: 0 14px;
}
.apexcharts-tooltip-title {
padding: 10px 14px;
}
} }
.profile-activity { .profile-activity {
.v-tab {
&.v-btn {
border: 1px dashed rgba(var(--v-theme-borderColor));
border-radius: 8px;
min-width: 90px;
overflow: hidden;
padding: 15px 20px;
overflow: hidden;
.v-tab { .v-btn__content {
display: block;
}
&.v-btn { &.v-tab--selected {
border: 1px dashed rgba(var(--v-theme-borderColor)); border: 1px solid rgba(var(--v-theme-borderColor));
border-radius: 8px; border-bottom: 2px solid;
min-width: 90px; }
overflow: hidden; .v-tab__slider {
padding: 15px 20px; opacity: 0;
overflow: hidden; }
.v-btn__content {
display: block;
}
&.v-tab--selected{
border: 1px solid rgba(var(--v-theme-borderColor));
border-bottom: 2px solid;
}
.v-tab__slider{
opacity: 0;
}
}
} }
}
.v-slide-group__content { .v-slide-group__content {
gap: 16px; gap: 16px;
} }
} }
.comment-box { .comment-box {
border-bottom: 1px solid rgba(var(--v-theme-borderColor)); border-bottom: 1px solid rgba(var(--v-theme-borderColor));
padding-bottom: 20px; padding-bottom: 20px;
margin-bottom: 20px; margin-bottom: 20px;
cursor: pointer; cursor: pointer;
&:last-child { &:last-child {
border-bottom: 0; border-bottom: 0;
padding-bottom: 0px; padding-bottom: 0px;
margin-bottom: 0px; margin-bottom: 0px;
} }
.comment-action {
opacity: 0;
transition: 0.5s;
}
&:hover {
.comment-action { .comment-action {
opacity: 0; opacity: 1;
transition: 0.5s;
}
&:hover {
.comment-action {
opacity: 1;
}
} }
}
} }
.todo-list { .todo-list {
border-bottom: 1px solid rgba(var(--v-theme-borderColor)); border-bottom: 1px solid rgba(var(--v-theme-borderColor));
padding-bottom: 25px; padding-bottom: 25px;
margin-bottom: 25px; margin-bottom: 25px;
cursor: pointer; cursor: pointer;
&:last-child {
border-bottom: 0;
padding-bottom: 20px;
margin-bottom: 20px;
}
&:last-child {
border-bottom: 0;
padding-bottom: 20px;
margin-bottom: 20px;
}
} }
.progress-cards { .progress-cards {
border-inline-end: 1px solid rgba(var(--v-theme-borderColor)); border-inline-end: 1px solid rgba(var(--v-theme-borderColor));
&:last-child { &:last-child {
border: 0; border: 0;
} }
@media screen and (max-width:991px) { @media screen and (max-width: 991px) {
border-inline-end: 0 !important border-inline-end: 0 !important;
} }
} }
.notification { .notification {
border-bottom: 1px solid rgba(var(--v-theme-borderColor)); border-bottom: 1px solid rgba(var(--v-theme-borderColor));
&:last-child { &:last-child {
border: 0; border: 0;
} }
} }
.earning-cards { .earning-cards {
@media screen and (max-width:991px){ @media screen and (max-width: 991px) {
.w-25{ .w-25 {
width: 100% !important; width: 100% !important;
&.border-e{ &.border-e {
border: 0 !important; border: 0 !important;
} }
.mobile-border{ .mobile-border {
border-bottom:1px solid rgba(var(--v-theme-borderColor)); border-bottom: 1px solid rgba(var(--v-theme-borderColor));
padding-bottom: 8px !important; padding-bottom: 8px !important;
} }
} }
} }
} }
+51 -51
View File
@@ -1,61 +1,61 @@
.ProseMirror { .ProseMirror {
padding: 20px; padding: 20px;
border: 1px solid rgb(var(--v-theme-inputBorder), 0.3); border: 1px solid rgb(var(--v-theme-inputBorder), 0.3);
border-radius: 0 0 12px 12px; border-radius: 0 0 12px 12px;
&.ProseMirror-focused { &.ProseMirror-focused {
outline-color: rgb(var(--v-theme-primary), 0.3) !important; outline-color: rgb(var(--v-theme-primary), 0.3) !important;
} }
> * + * { > * + * {
margin-top: 0.75em; margin-top: 0.75em;
} }
ul, ul,
ol { ol {
padding: 0 1rem; padding: 0 1rem;
} }
h1, h1,
h2, h2,
h3, h3,
h4, h4,
h5, h5,
h6 { h6 {
line-height: 1.1; line-height: 1.1;
} }
code {
background-color: rgba(#616161, 0.1);
color: #616161;
}
pre {
background: #0d0d0d;
color: #fff;
font-family: "JetBrainsMono", monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
code { code {
background-color: rgba(#616161, 0.1); color: inherit;
color: #616161; padding: 0;
background: none;
font-size: 0.8rem;
} }
}
pre { img {
background: #0d0d0d; max-width: 100%;
color: #fff; height: auto;
font-family: 'JetBrainsMono', monospace; }
padding: 0.75rem 1rem;
border-radius: 0.5rem;
code { blockquote {
color: inherit; padding-left: 1rem;
padding: 0; border-left: 2px solid rgba(#0d0d0d, 0.1);
background: none; }
font-size: 0.8rem;
}
}
img { hr {
max-width: 100%; border: none;
height: auto; border-top: 2px solid rgba(#0d0d0d, 0.1);
} margin: 2rem 0;
}
blockquote {
padding-left: 1rem;
border-left: 2px solid rgba(#0d0d0d, 0.1);
}
hr {
border: none;
border-top: 2px solid rgba(#0d0d0d, 0.1);
margin: 2rem 0;
}
} }
+83
View File
@@ -0,0 +1,83 @@
import { type Ref, reactive, toRefs, unref, watch } from "vue"
import { isNil } from "lodash"
import blogPostsApi, { type BlogPost } from "@/api/blog-posts-api"
export { type BlogPost }
export function useBlogPost(idOrSlug: Ref<number | string | null | undefined>) {
const state = reactive<{
blogPost: BlogPost | null
isLoading: boolean
isErrored: boolean
}>({
blogPost: null,
isLoading: false,
isErrored: false,
})
async function fetch(): Promise<BlogPost> {
const staticIdOrSlug = unref(idOrSlug)
if (isNil(staticIdOrSlug)) {
throw new Error("idOrSlug is required")
}
state.isLoading = true
try {
const { blogPost } = await blogPostsApi.get(staticIdOrSlug)
state.isErrored = false
state.blogPost = blogPost
return blogPost
} catch (error) {
console.error("Failed to fetch blog post:", error)
state.isErrored = true
throw error
} finally {
state.isLoading = false
}
}
async function save(): Promise<BlogPost> {
const staticIdOrSlug = unref(idOrSlug)
if (isNil(staticIdOrSlug)) {
throw new Error("idOrSlug is required")
}
if (isNil(state.blogPost)) {
throw new Error("No blog post to save")
}
state.isLoading = true
try {
const { blogPost } = await blogPostsApi.update(staticIdOrSlug, state.blogPost)
state.isErrored = false
state.blogPost = blogPost
return blogPost
} catch (error) {
console.error("Failed to save blog post:", error)
state.isErrored = true
throw error
} finally {
state.isLoading = false
}
}
watch(
() => unref(idOrSlug),
async (newIdOrSlug) => {
if (isNil(newIdOrSlug)) return
await fetch()
},
{ immediate: true }
)
return {
...toRefs(state),
fetch,
refresh: fetch,
save,
}
}
export default useBlogPost
+67
View File
@@ -0,0 +1,67 @@
import { type Ref, reactive, toRefs, ref, unref, watch } from "vue"
import blogPostsApi, {
type BlogPostIndexView,
type BlogPostWhereOptions,
type BlogPostFiltersOptions,
type BlogPostQueryOptions,
} from "@/api/blog-posts-api"
export {
type BlogPostIndexView,
type BlogPostWhereOptions,
type BlogPostFiltersOptions,
type BlogPostQueryOptions,
}
export function useBlogPosts(
queryOptions: Ref<BlogPostQueryOptions> = ref({}),
{ skipWatchIf = () => false }: { skipWatchIf?: () => boolean } = {}
) {
const state = reactive<{
blogPosts: BlogPostIndexView[]
totalCount: number
isLoading: boolean
isErrored: boolean
}>({
blogPosts: [],
totalCount: 0,
isLoading: false,
isErrored: false,
})
async function fetch(): Promise<BlogPostIndexView[]> {
state.isLoading = true
try {
const { blogPosts, totalCount } = await blogPostsApi.list(unref(queryOptions))
state.isErrored = false
state.blogPosts = blogPosts
state.totalCount = totalCount
return blogPosts
} catch (error) {
console.error("Failed to fetch blog posts:", error)
state.isErrored = true
throw error
} finally {
state.isLoading = false
}
}
watch(
() => [skipWatchIf(), unref(queryOptions)],
async ([skip]) => {
if (skip) return
await fetch()
},
{ deep: true, immediate: true }
)
return {
...toRefs(state),
fetch,
refresh: fetch,
}
}
export default useBlogPosts
+15
View File
@@ -1,7 +1,9 @@
import { computed, reactive, toRefs } from "vue" import { computed, reactive, toRefs } from "vue"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { isNil, isNull, isUndefined } from "lodash"
import currentUserApi, { UserRoles, type UserAsShow } from "@/api/current-user-api" import currentUserApi, { UserRoles, type UserAsShow } from "@/api/current-user-api"
import { PreferenceKeys, PreferenceValueMap } from "@/api/users/preferences-api"
export { UserRoles, type UserAsShow } export { UserRoles, type UserAsShow }
@@ -35,6 +37,18 @@ export function useCurrentUser<IsLoaded extends boolean = false>() {
return state.currentUser?.roles.includes(UserRoles.SYSTEM_ADMIN) return state.currentUser?.roles.includes(UserRoles.SYSTEM_ADMIN)
}) })
function getPreference<K extends PreferenceKeys>(key: K): PreferenceValueMap[K] | null {
if (isNull(state.currentUser)) return null
const { preferences } = state.currentUser
if (isUndefined(preferences)) return null
const preference = preferences.find((p) => p.key === key)
if (isNil(preference)) return null
return preference.value as PreferenceValueMap[K]
}
async function fetch(): Promise<UserAsShow> { async function fetch(): Promise<UserAsShow> {
state.isLoading = true state.isLoading = true
try { try {
@@ -68,6 +82,7 @@ export function useCurrentUser<IsLoaded extends boolean = false>() {
reset, reset,
// helpers // helpers
isSystemAdmin, isSystemAdmin,
getPreference,
} }
} }
+67
View File
@@ -0,0 +1,67 @@
import { type Ref, reactive, toRefs, ref, unref, watch } from "vue"
import publicBlogPostsApi, {
type PublicBlogPostIndexView,
type PublicBlogPostWhereOptions,
type PublicBlogPostFiltersOptions,
type PublicBlogPostQueryOptions,
} from "@/api/public/blog-posts-api"
export {
type PublicBlogPostIndexView,
type PublicBlogPostWhereOptions,
type PublicBlogPostFiltersOptions,
type PublicBlogPostQueryOptions,
}
export function usePublicBlogPosts(
queryOptions: Ref<PublicBlogPostQueryOptions> = ref({}),
{ skipWatchIf = () => false }: { skipWatchIf?: () => boolean } = {}
) {
const state = reactive<{
publicBlogPosts: PublicBlogPostIndexView[]
totalCount: number
isLoading: boolean
isErrored: boolean
}>({
publicBlogPosts: [],
totalCount: 0,
isLoading: false,
isErrored: false,
})
async function fetch(): Promise<PublicBlogPostIndexView[]> {
state.isLoading = true
try {
const { blogPosts, totalCount } = await publicBlogPostsApi.list(unref(queryOptions))
state.isErrored = false
state.publicBlogPosts = blogPosts
state.totalCount = totalCount
return blogPosts
} catch (error) {
console.error("Failed to fetch blog posts:", error)
state.isErrored = true
throw error
} finally {
state.isLoading = false
}
}
watch(
() => [skipWatchIf(), unref(queryOptions)],
async ([skip]) => {
if (skip) return
await fetch()
},
{ deep: true, immediate: true }
)
return {
...toRefs(state),
fetch,
refresh: fetch,
}
}
export default usePublicBlogPosts
+6 -3
View File
@@ -1,15 +1,18 @@
import { isNil, isEmpty } from "lodash" import { isNil, isEmpty } from "lodash"
import { DateTime } from "luxon" import { DateTime } from "luxon"
export function formatDate(input: string | Date | undefined): string { export function formatDate(
input: string | Date | undefined | null,
formatOption: string = "yyyy-MM-dd"
): string {
if (isNil(input) || isEmpty(input)) { if (isNil(input) || isEmpty(input)) {
return "" return ""
} else if (typeof input == "string") { } else if (typeof input == "string") {
const parsed = DateTime.fromISO(input, { zone: "utc" }) const parsed = DateTime.fromISO(input, { zone: "utc" })
const isMidnight = parsed.hour === 0 && parsed.minute === 0 && parsed.second === 0 const isMidnight = parsed.hour === 0 && parsed.minute === 0 && parsed.second === 0
return isMidnight ? parsed.toFormat("yyyy-MM-dd") : parsed.toLocal().toFormat("yyyy-MM-dd") return isMidnight ? parsed.toFormat(formatOption) : parsed.toLocal().toFormat(formatOption)
} else if (input instanceof Date) { } else if (input instanceof Date) {
return DateTime.fromJSDate(input).toLocal().toFormat("yyyy-MM-dd") return DateTime.fromJSDate(input).toLocal().toFormat(formatOption)
} else { } else {
return "" return ""
} }