Compare commits

...

6 Commits

Author SHA1 Message Date
burkkyy 133d607b2f Removing bad padding 2026-07-17 04:54:06 -07:00
burkkyy de28a67417 Adding a ton of stuff 2026-07-17 04:39:18 -07:00
burkkyy 8d9fe8e75d First draft of HomePageUpgrade from claude 2026-07-16 16:42:37 -07:00
burkkyy 9dfee98b84 UI upgrades
Build and Push Docker Image / build (push) Successful in 52s
2026-07-15 01:54:42 -07:00
burkkyy d33aef1680 formatting 2026-07-15 01:01:19 -07:00
burkkyy a0d5a7ec68 Prepping public app bar for mobile view 2026-07-15 00:45:28 -07:00
91 changed files with 3954 additions and 1974 deletions
@@ -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
export { BlogPostsController } from "./blog-posts-controller"
export { CurrentUserController } from "./current-user-controller"
export { FlashcardDecksController } from "./flashcard-decks-controller"
export { FlashcardsController } from "./flashcards-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,
// options?: FindByPkOptions<M>,
// ): Promise<M | null>;
public static async findByIdentifierOrPk<M extends BaseModel>(
public static async findBySlugOrPk<M extends BaseModel>(
this: ModelStatic<M>,
identifierOrPk: string | number,
slugOrPk: unknown,
options?: Omit<FindOptions<Attributes<M>>, "where">
): Promise<M | null> {
if (typeof identifierOrPk === "number" || !isNaN(Number(identifierOrPk))) {
const primaryKey = identifierOrPk
if (typeof slugOrPk === "number" || !isNaN(Number(slugOrPk))) {
const primaryKey = slugOrPk
return this.findByPk(primaryKey, options)
}
const identifier = identifierOrPk
if (!("identifier" in this.getAttributes())) {
throw new Error(`${this.name} does not have a 'identifier' attribute.`)
const slug = slugOrPk
if (!("slug" in this.getAttributes())) {
throw new Error(`${this.name} does not have a 'slug' attribute.`)
}
return this.findOne({
...options,
// @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"
// Models
import BlogPost from "@/models/blog-post"
import Flashcard from "@/models/flashcard"
import FlashcardDeck from "@/models/flashcard-deck"
import User, { UserRoles } from "@/models/user"
import UserPreference from "@/models/user-preference"
db.addModels([
Flashcard,
FlashcardDeck,
User,
])
db.addModels([BlogPost, Flashcard, FlashcardDeck, User, UserPreference])
// Lazy load scopes
BlogPost.establishScopes()
Flashcard.establishScopes()
FlashcardDeck.establishScopes()
User.establishScopes()
UserPreference.establishScopes()
export {
Flashcard,
FlashcardDeck,
User,
UserRoles,
}
export { BlogPost, Flashcard, FlashcardDeck, User, UserRoles, UserPreference }
// Special db instance will all models loaded
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,
AutoIncrement,
Default,
HasMany,
Index,
NotNull,
PrimaryKey,
@@ -18,6 +19,7 @@ import {
import { isArray, isNil } from "lodash"
import BaseModel from "@/models/base-model"
import UserPreference from "@/models/user-preference"
/** Keep in sync with web/src/api/users-api.ts */
export enum UserRoles {
@@ -98,12 +100,22 @@ export class User extends BaseModel<InferAttributes<User>, InferCreationAttribut
}
// Associations
@HasMany(() => UserPreference, {
foreignKey: {
name: "userId",
allowNull: false,
},
inverse: "user",
})
declare userPreferences?: NonAttribute<UserPreference[]>
// Scopes
static establishScopes(): void {
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
export { type BaseScopeOptions } from "./base-policy"
export { BlogPostsPolicy } from "./blog-posts-policy"
export { FlashcardDecksPolicy } from "./flashcard-decks-policy"
export { FlashcardsPolicy } from "./flashcards-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 { CurrentUserController, FlashcardDecksController, FlashcardsController, UsersController } from "@/controllers"
import {
BlogPostsController,
CurrentUserController,
FlashcardDecksController,
FlashcardsController,
Public,
Users,
UsersController,
} from "@/controllers"
export const router = Router()
@@ -29,6 +37,8 @@ router.route("/_status").get((_req: Request, res: Response) => {
})
// 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
router.use("/api", jwtMiddleware, authorizationMiddleware)
@@ -41,6 +51,7 @@ router
.get(UsersController.show)
.patch(UsersController.update)
.delete(UsersController.destroy)
router.route("/api/users/:userId/preferences/:key").patch(Users.PreferencesController.update)
router
.route("/api/flashcard-decks")
@@ -52,16 +63,20 @@ router
.patch(FlashcardDecksController.update)
.delete(FlashcardDecksController.destroy)
router
.route("/api/flashcards")
.get(FlashcardsController.index)
.post(FlashcardsController.create)
router.route("/api/flashcards").get(FlashcardsController.index).post(FlashcardsController.create)
router
.route("/api/flashcards/:flashcardId")
.get(FlashcardsController.show)
.patch(FlashcardsController.update)
.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
router.use("/api", (req: Request, res: Response) => {
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 {
ShowSerializer as UserPreferenceShowSerializer,
type UserPreferenceShowView,
} from "@/serializers/user-preferences/show-serializer"
export type UserShowView = Pick<
User,
"id" | "email" | "firstName" | "lastName" | "displayName" | "roles" | "createdAt" | "updatedAt"
>
> & {
preferences?: UserPreferenceShowView[]
}
export class ShowSerializer extends BaseSerializer<User> {
perform(): UserShowView {
const { userPreferences } = this.record
const serializedUserPreferences = isUndefined(userPreferences)
? undefined
: this.serializeUserPreferences(userPreferences)
return {
...pick(this.record, [
"id",
@@ -21,8 +33,15 @@ export class ShowSerializer extends BaseSerializer<User> {
"createdAt",
"updatedAt",
]),
preferences: serializedUserPreferences,
}
}
private serializeUserPreferences(userPreferences: UserPreference[]) {
return userPreferences.map((userPreference) =>
UserPreferenceShowSerializer.perform(userPreference)
)
}
}
export default ShowSerializer
+1
View File
@@ -1,4 +1,5 @@
// Bundled exports
export * as BlogPosts from "./blog-posts"
export * as FlashcardDecks from "./flashcard-decks"
export * as Flashcards from "./flashcards"
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 Flashcards from "./flashcards"
export * as Users from "./users"
+2
View File
@@ -5,3 +5,5 @@ export { DestroyService } from "./destroy-service"
// Special Services
export { EnsureFromAuth0TokenService } from "./ensure-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)
}
+1 -1
View File
@@ -11,7 +11,7 @@
crossorigin
/>
<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"
/>
+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 { UserRoles, type User } from "@/api/users-api"
import { PreferenceAsShow } from "@/api/users/preferences-api"
export { UserRoles }
export type UserAsShow = Pick<
User,
"id" | "email" | "firstName" | "lastName" | "displayName" | "roles" | "createdAt" | "updatedAt"
>
> & {
preferences?: PreferenceAsShow[]
}
export const currentUserApi = {
async get(): Promise<{
+4 -2
View File
@@ -21,8 +21,10 @@ export const httpClient = axios.create({
})
httpClient.interceptors.request.use(async (config) => {
// Only add the Authorization header to requests that start with "/api"
if (config.url?.startsWith("/api")) {
// Only add the Authorization header to authenticated "/api" requests.
// "/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()
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-cyan">@</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>
</RouterLink>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue"
const props = withDefaults(
withDefaults(
defineProps<{
to?: string
animated?: boolean
}>(),
{
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>
<style>
+30 -29
View File
@@ -18,22 +18,6 @@
<v-spacer />
<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>
</div>
<div v-else>
@@ -46,24 +30,19 @@
<div class="mr-3">
<!-- <AppLogo /> -->
<PublicAppLogo
to="/dashboard"
to="/"
:animated="false"
/>
</div>
<v-tabs
class="ml-2"
color="primary"
>
<v-tab
:to="{ name: 'DashboardPage' }"
text="Dashboard"
<div class="d-flex align-center">
<v-divider vertical />
<ExactingBreadcrumbs
class="appbar-title"
density="compact"
:items="breadcrumbs"
/>
<v-tab
:to="{ name: 'FlashcardsPage' }"
text="Flashcards"
/>
</v-tabs>
</div>
<v-spacer />
@@ -92,8 +71,30 @@
<script setup lang="ts">
import { useDisplay } from "vuetify"
import useBreadcrumbs from "@/use/use-breadcrumbs"
import PublicAppLogo from "@/components/common/PublicAppLogo.vue"
import ProfileMenu from "@/components/layout/ProfileMenu.vue"
import ExactingBreadcrumbs from "@/components/layout/ExactingBreadcrumbs.vue"
const { mobile } = useDisplay()
const { breadcrumbs } = useBreadcrumbs(undefined, undefined, {
baseCrumb: {
title: "Dashboard",
to: {
name: "DashboardPage",
},
},
})
</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
variant="outlined"
block
text="Logout"
@click="signOut"
>Logout</v-btn
>
/>
</div>
</v-sheet>
</v-menu>
+50 -18
View File
@@ -1,27 +1,59 @@
<template>
<v-app-bar
id="top"
elevation="6"
height="60"
class="main-head pl-2"
>
<div class="mr-3">
<PublicAppLogo
to="/"
:animated="isHomePage"
/>
</div>
<div v-if="mobile">
<v-app-bar
id="top"
elevation="6"
height="60"
class="main-head pl-2"
>
<div class="mr-3">
<PublicAppLogo to="/" />
</div>
<v-spacer />
</v-app-bar>
<v-spacer />
<ProfileMenu v-if="isAuthenticated" />
<v-btn
v-else
color="primary"
variant="tonal"
text="Sign in"
:to="{ name: 'SignInPage' }"
/>
</v-app-bar>
</div>
<div v-else>
<v-app-bar
id="top"
elevation="6"
height="60"
class="main-head pl-2"
>
<div class="mr-3">
<PublicAppLogo to="/" />
</div>
<v-spacer />
<ProfileMenu v-if="isAuthenticated" />
<v-btn
v-else
color="primary"
variant="tonal"
text="Sign in"
:to="{ name: 'SignInPage' }"
/>
</v-app-bar>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue"
import { useRoute } from "vue-router"
import { useDisplay } from "vuetify"
import { useAuth0 } from "@auth0/auth0-vue"
import ProfileMenu from "@/components/layout/ProfileMenu.vue"
import PublicAppLogo from "@/components/common/PublicAppLogo.vue"
const route = useRoute()
const isHomePage = computed(() => route.name === "HomePage")
const { mobile } = useDisplay()
const { isAuthenticated } = useAuth0()
</script>
+204 -2
View File
@@ -1,22 +1,224 @@
<template>
<v-app>
<PublicAppBar />
<PublicAppBar class="mb-2" />
<v-main>
<v-container
fluid
: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-main>
<footer class="public-footer">
<span>&copy; {{ currentYear }} Caleb Burke</span>
<div class="social-links">
<a
href="https://github.com/burkkyy"
class="social-link"
title="GitHub"
target="_blank"
rel="noopener noreferrer"
>
<v-icon size="24">mdi-github</v-icon>
</a>
<a
href="https://gitea.burke.host/burkkyy"
class="social-link"
title="Gitea"
target="_blank"
rel="noopener noreferrer"
>
<svg
class="social-svg"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<title>Gitea</title>
<path
d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"
/>
</svg>
</a>
<a
href="https://discord.com/users/burkkyy"
class="social-link"
title="Discord"
target="_blank"
rel="noopener noreferrer"
>
<svg
class="social-svg"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<title>Discord</title>
<path
d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"
/>
</svg>
</a>
<a
href="https://www.linkedin.com/in/calebburke91/"
class="social-link"
title="LinkedIn"
target="_blank"
rel="noopener noreferrer"
>
<v-icon size="24">mdi-linkedin</v-icon>
</a>
<a
href="mailto:calebburke91@gmail.com"
class="social-link"
title="Gmail"
target="_blank"
rel="noopener noreferrer"
>
<v-icon size="24">mdi-gmail</v-icon>
</a>
</div>
</footer>
</v-app>
</template>
<script setup lang="ts">
import { useDisplay } from "vuetify"
import { useRoute } from "vue-router"
import { DateTime } from "luxon"
import PublicAppBar from "@/components/layout/PublicAppBar.vue"
const { mobile } = useDisplay()
const route = useRoute()
const currentYear = DateTime.local().year
</script>
<style scoped>
.public-content {
max-width: 1300px;
margin: 0 auto;
}
.sidebar {
position: sticky;
top: 24px;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 2px;
}
.sidebar-nav-link {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 10px;
margin: 0 -10px;
border-radius: 6px;
font-family: ui-monospace, Menlo, Monaco, Consolas, "Ubuntu Mono", monospace;
font-size: 14px;
color: rgb(var(--v-theme-textSecondary));
text-decoration: none;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.sidebar-nav-link:hover,
.sidebar-nav-link.router-link-active {
background-color: rgb(var(--v-theme-hoverColor));
color: rgb(var(--v-theme-textPrimary));
}
.social-links {
display: flex;
gap: 16px;
}
.social-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: rgb(var(--v-theme-textSecondary));
text-decoration: none;
transition: color 0.15s ease;
}
.social-link:hover {
color: rgb(var(--v-theme-primary));
}
.social-svg {
width: 24px;
height: 24px;
}
.public-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 24px;
border-top: 1px solid rgb(var(--v-theme-borderColor));
font-family: ui-monospace, Menlo, Monaco, Consolas, "Ubuntu Mono", monospace;
font-size: 12px;
color: rgb(var(--v-theme-textSecondary));
}
@media (max-width: 960px) {
.sidebar {
position: static;
}
.public-footer {
justify-content: center;
text-align: center;
}
}
</style>
+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
</AppCard>
</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>
<script lang="ts" setup>
import useBreadcrumbs from "@/use/use-breadcrumbs"
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 DashboardCard from "@/components/common/DashboardCard.vue"
const { isSystemAdmin } = useCurrentUser<true>()
useBreadcrumbs()
const { totalCount: flashcardDecksCount } = useFlashcardDecks()
const { totalCount: blogPostsCount } = useBlogPosts()
useBreadcrumbs("Dashboard", [])
</script>
+8 -1
View File
@@ -154,5 +154,12 @@ function openFlashcardCreateDialog() {
flashcardCreateDialog.value?.show()
}
useBreadcrumbs("Study")
useBreadcrumbs("Flashcards", [
{
title: "Flashcards",
to: {
name: "FlashcardsPage",
},
},
])
</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"
style="max-width: 500px"
>
<!-- <img
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>
<PublicAppLogo />
<h6 class="text-h6 text-medium-emphasis d-flex align-center mt-6 font-weight-medium">
<v-btn
block
@@ -37,10 +27,6 @@
</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
block
variant="outlined"
@@ -63,20 +49,6 @@
</div>
</div>
</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>
</div>
</template>
@@ -85,7 +57,7 @@
import { onMounted } from "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"
const { reset: resetCurrentUser } = useCurrentUser()
@@ -1,105 +1,53 @@
<template>
<div>
<v-row>
<!-- Users Card -->
<v-col
v-if="isSystemAdmin"
cols="12"
md="6"
lg="4"
>
<v-card
elevation="10"
<DashboardCard
:to="{ name: 'administration/UsersPage' }"
hover
title="Users"
subtitle="Manage users and permissions"
:count="usersCount"
count-label="Total Users"
color="success"
>
<v-card-item>
<div class="d-flex align-center">
<v-avatar
color="success"
size="56"
class="mr-4"
>
<v-icon
size="32"
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>
<template #icon>
<v-icon
size="32"
color="white"
>
mdi-account-group
</v-icon>
</template>
</DashboardCard>
</v-col>
<!-- Settings Card -->
<v-col
v-if="isSystemAdmin"
cols="12"
md="6"
lg="4"
>
<v-card
elevation="10"
<DashboardCard
:to="{ name: 'administration/SettingsPage' }"
hover
title="Settings"
subtitle="System configuration"
count-label="Configure System"
color="info"
>
<v-card-item>
<div class="d-flex align-center">
<v-avatar
color="info"
size="56"
class="mr-4"
>
<v-icon
size="32"
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>
<template #icon>
<v-icon
size="32"
color="white"
>
mdi-cog
</v-icon>
</template>
</DashboardCard>
</v-col>
</v-row>
</div>
@@ -110,6 +58,8 @@ import useBreadcrumbs from "@/use/use-breadcrumbs"
import useUsers from "@/use/use-users"
import useCurrentUser from "@/use/use-current-user"
import DashboardCard from "@/components/common/DashboardCard.vue"
const { isSystemAdmin } = useCurrentUser()
const { totalCount: usersCount } = useUsers()
+13 -3
View File
@@ -4,7 +4,6 @@ import { authGuard } from "@auth0/auth0-vue"
import { APPLICATION_NAME } from "@/config"
import administrationRoutes from "@/routes/administration-routes"
import { authorizationGuard } from "@/utils/authorization-guards"
import publicRoutes from "@/routes/public-routes"
const routes: RouteRecordRaw[] = [
{
@@ -14,9 +13,21 @@ const routes: RouteRecordRaw[] = [
children: [
{
path: "",
redirect: { name: "HomePage" },
},
{
path: "home",
name: "HomePage",
component: () => import("@/pages/HomePage.vue"),
},
{
name: "BlogPage",
path: "blog",
component: () => import("@/pages/BlogPage.vue"),
meta: {
title: "Blog",
},
},
],
},
{
@@ -37,7 +48,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
path: "",
redirect: "sign-in",
redirect: { name: "DashboardPage" },
},
{
name: "DashboardPage",
@@ -95,7 +106,6 @@ const routes: RouteRecordRaw[] = [
},
],
},
...publicRoutes,
...administrationRoutes,
{
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
+17 -17
View File
@@ -1,24 +1,24 @@
.single-line-alert {
.v-alert__close,
.v-alert__prepend {
align-self: center !important;
}
.v-alert__close,
.v-alert__prepend {
align-self: center !important;
}
}
@media (max-width: 500px) {
.single-line-alert {
display: flex;
flex-wrap: wrap;
.single-line-alert {
display: flex;
flex-wrap: wrap;
.v-alert__append {
margin-inline-start: 0px;
}
.v-alert__close {
margin-left: auto;
}
.v-alert__content {
width: 100%;
margin-top: 5px;
}
.v-alert__append {
margin-inline-start: 0px;
}
.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-divider{
padding: 0 0 !important;
}
.v-breadcrumbs-item--link{
text-decoration: none;
}
.v-breadcrumbs {
.v-breadcrumbs-divider {
padding: 0 0 !important;
}
.v-breadcrumbs-item--link {
text-decoration: none;
}
}
+14 -14
View File
@@ -1,22 +1,22 @@
.v-btn-group .v-btn {
height: inherit !important;
height: inherit !important;
}
.v-btn-group {
border-color: rgb(var(--v-theme-borderColor)) !important;
border-color: rgb(var(--v-theme-borderColor)) !important;
}
.v-btn{
text-transform: capitalize;
letter-spacing: 0;
border-radius: 30px;
.v-btn {
text-transform: capitalize;
letter-spacing: 0;
border-radius: 30px;
&.v-btn--variant-elevated{
box-shadow: none !important;
}
.v-btn--slim{
padding: 0 15px;
}
&.v-btn--variant-elevated {
box-shadow: none !important;
}
.v-btn--slim {
padding: 0 15px;
}
}
.v-btn--elevated:hover{
box-shadow: none;
.v-btn--elevated:hover {
box-shadow: none;
}
+46 -45
View File
@@ -1,69 +1,70 @@
// Outline Card
.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-flat {
color: rgb(var(--v-theme-textPrimary));
color: rgb(var(--v-theme-textPrimary));
}
.card-hover {
transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
&:hover {
scale: 1.01;
transition: all 0.1s ease-in 0s;
}
transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
&:hover {
scale: 1.01;
transition: all 0.1s ease-in 0s;
}
}
.v-card {
width: 100%;
overflow: visible;
.color-inherits {
color: inherit;
}
.feature-card {
.v-responsive__content {
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;
width: 100%;
overflow: visible;
.color-inherits {
color: inherit;
}
.feature-card {
.v-responsive__content {
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;
}
}
// Theme cards
.cardBordered {
.v-card {
box-shadow: none !important;
border: 1px solid rgb(var(--v-theme-borderColor));
}
.v-card {
box-shadow: none !important;
border: 1px solid rgb(var(--v-theme-borderColor));
}
}
.elevation-o-card{
.v-card-item{
padding: 0.625rem 1rem;
}
.elevation-o-card {
.v-card-item {
padding: 0.625rem 1rem;
}
}
.card-title{
font-size: 18px;
font-weight: 500;
color:rgb(var(--v-theme-textPrimary));
.card-title {
font-size: 18px;
font-weight: 500;
color: rgb(var(--v-theme-textPrimary));
}
.card-subtitle{
font-size: 14px;
font-weight: 400;
color:rgb(var(--v-theme-textSecondary));
.card-subtitle {
font-size: 14px;
font-weight: 400;
color: rgb(var(--v-theme-textSecondary));
}
.dark-card-title{
font-size: 18px;
font-weight: 500;
color:rgb(var(--v-theme-textPrimary));
.dark-card-title {
font-size: 18px;
font-weight: 500;
color: rgb(var(--v-theme-textPrimary));
}
+1 -1
View File
@@ -1,3 +1,3 @@
.v-pagination__item--is-active .v-btn__overlay {
opacity: 0.15 !important;
opacity: 0.15 !important;
}
+68 -81
View File
@@ -1,99 +1,86 @@
.v-table {
&.datatabels {
&.productlist {
.v-data-table-header__content span {
color: rgb(var(--v-theme-textPrimary));
}
&.datatabels {
&.productlist {
.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-toolbar {
.v-input__control {
max-width: 300px;
}
.v-selection-control--dirty .v-selection-control__input>.v-icon {
color: rgb(var(--v-theme-primary));
.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 {
color: rgb(var(--v-theme-primary));
}
}
}
@media screen and (max-width:1368px) {
.v-table {
&.datatabels {
&.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;
}
}
}
}
}
}
@media screen and (max-width: 1368px) {
.v-table {
&.datatabels {
&.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;
}
}
}
}
}
}
}
}
}
.v-pagination {
.v-pagination__list {
.v-pagination__item--is-active {
.v-btn {
.v-btn__overlay {
opacity: 0;
}
background-color: rgb(var(--v-theme-grey100)) !important;
}
.v-pagination__list {
.v-pagination__item--is-active {
.v-btn {
.v-btn__overlay {
opacity: 0;
}
background-color: rgb(var(--v-theme-grey100)) !important;
}
}
}
}
@@ -1,6 +1,6 @@
.v-expansion-panel-title__overlay{
background: rgba(var(--v-theme-primary));
.v-expansion-panel-title__overlay {
background: rgba(var(--v-theme-primary));
}
.v-expansion-panel:not(:first-child)::after {
border-color: transparent !important;
border-color: transparent !important;
}
+7 -8
View File
@@ -2,17 +2,17 @@
.v-field--variant-outlined .v-field__outline__start.v-locale--is-ltr,
.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-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 {
font-size: 14px;
color: rgba(var(--v-theme-textPrimary));
font-size: 14px;
color: rgba(var(--v-theme-textPrimary));
}
// select outlined
@@ -20,10 +20,9 @@
.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__end {
opacity: 1;
opacity: 1;
}
.v-field--active .v-label.v-field-label{
color: rgb(var(--v-theme-textPrimary));
.v-field--active .v-label.v-field-label {
color: rgb(var(--v-theme-textPrimary));
}
+11 -11
View File
@@ -2,34 +2,34 @@
.v-input--density-default,
.v-field--variant-solo,
.v-field--variant-filled {
--v-input-control-height: 51px;
--v-input-padding-top: 14px;
--v-input-control-height: 51px;
--v-input-padding-top: 14px;
}
// comfortable
.v-input--density-comfortable {
--v-input-control-height: 44px;
--v-input-control-height: 44px;
}
// compact
.v-input--density-compact {
--v-input-padding-top: 10px;
--v-input-padding-top: 10px;
}
.v-label {
font-size: 14px;
opacity: 0.7;
font-weight: 500 !important;
font-size: 14px;
opacity: 0.7;
font-weight: 500 !important;
}
.v-switch .v-label,
.v-checkbox .v-label {
opacity: 1;
opacity: 1;
}
.v-text-field__suffix {
opacity: 1;
padding-left: 20px;
opacity: 1;
padding-left: 20px;
}
.shadow-none .v-field--variant-solo {
box-shadow: none !important;
box-shadow: none !important;
}
+11 -10
View File
@@ -1,18 +1,19 @@
.v-time-picker-clock{
background: rgb(var(--v-theme-grey100)) ;
.v-time-picker-clock {
background: rgb(var(--v-theme-grey100));
}
.v-time-picker-controls__ampm__btn.v-btn.v-btn--density-default{
border: 0 !important;
.v-time-picker-controls__ampm__btn.v-btn.v-btn--density-default {
border: 0 !important;
}
.v-stepper-header,.v-stepper.v-sheet{
box-shadow: none !important;
.v-stepper-header,
.v-stepper.v-sheet {
box-shadow: none !important;
}
.v-time-picker-controls__time__btn.v-btn--density-default.v-btn {
width: 55px !important;
height: 55px !important;
font-size: 30px;
width: 55px !important;
height: 55px !important;
font-size: 30px;
}
.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-item:hover > .v-list-item__overlay {
opacity: 1;
z-index: 1;
}
.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:hover > .v-list-item__overlay {
opacity: 1;
z-index: 1;
}
.v-list-item--variant-text {
.v-list-item__overlay {
background-color: rgb(var(--v-theme-hoverColor));
}
.v-list-item.v-list-item--active{
.v-list-item__overlay{
opacity: 1;
}
background: rgb(var(--v-theme-hoverColor));
}
}
.mail-items{
min-height: 40px !important;
margin-bottom: 5px !important;
}
.v-list-item__prepend,
.v-list-item__content {
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{
font-size: 14px;
.v-list-item-title {
font-size: 14px;
}
@@ -1,6 +1,6 @@
// For checkbox & radios
.v-selection-control__input > .v-icon.mdi-checkbox-blank-outline,
.v-selection-control__input > .v-icon.mdi-radiobox-blank {
color: rgb(var(--v-theme-inputBorder));
opacity: 1;
color: rgb(var(--v-theme-inputBorder));
opacity: 1;
}
+10 -10
View File
@@ -8,26 +8,26 @@
box-shadow: $box-shadow !important;
}
.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 {
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 {
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{
box-shadow: 0px 12px 12px -6px rgba(0,0,0,0.15) !important;
.elevation-4 {
box-shadow: 0px 12px 12px -6px rgba(0, 0, 0, 0.15) !important;
}
.elevation-5
{
box-shadow: 1px 0 7px rgba(0, 0, 0, .05)!important;
.elevation-5 {
box-shadow: 1px 0 7px rgba(0, 0, 0, 0.05) !important;
}
.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 {
box-shadow: none;
}
}
+5 -4
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 {
background: rgb(var(--v-theme-primary)) !important;
.v-stepper-item--selected .v-stepper-item__avatar.v-avatar,
.v-stepper-item--complete .v-stepper-item__avatar.v-avatar {
background: rgb(var(--v-theme-primary)) !important;
}
.v-stepper-item__avatar.v-avatar {
background: rgba(var(--v-theme-primary), var(--v-medium-emphasis-opacity)) !important;
color: rgb(var(--v-theme-on-primary)) !important;
background: rgba(var(--v-theme-primary), var(--v-medium-emphasis-opacity)) !important;
color: rgb(var(--v-theme-on-primary)) !important;
}
+64 -76
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) > 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{
th.v-data-table__th{
font-size:16px;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
td.v-data-table__td{
font-size: 14px;
text-wrap: nowrap;
}
.v-data-table-footer{
padding: 15px 8px;
}
.v-data-table-header__sort-badge{
background-color:rgb(var(--v-theme-borderColor)) !important;
}
.tdhead{
font-size:16px;
}
.v-data-table {
th.v-data-table__th {
font-size: 16px;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
td.v-data-table__td {
font-size: 14px;
text-wrap: nowrap;
}
.v-data-table-footer {
padding: 15px 8px;
}
.v-data-table-header__sort-badge {
background-color: rgb(var(--v-theme-borderColor)) !important;
}
.tdhead {
font-size: 16px;
}
}
@media screen and (max-width:767px) {
.v-data-table-footer{
justify-content: center;
}
@media screen and (max-width: 767px) {
.v-data-table-footer {
justify-content: center;
}
}
.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 {
thead {
th {
font-weight: 600 !important;
}
&:first-child {
padding-left: 0 !important;
}
tbody {
tr {
td {
padding: 16px 16px !important;
}
}
&:last-child {
padding-right: 0 !important;
}
}
}
}
tbody {
tr {
td {
padding: 8px 24px !important;
&.invoice-table {
.v-table__wrapper {
table {
thead {
th {
font-weight: 600 !important;
padding: 0px 24px !important;
&:first-child {
padding-left: 0 !important;
}
&:first-child {
padding-left: 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;
}
}
}
}
&:last-child {
padding-right: 0 !important;
}
}
}
}
}
}
}
}
+8 -9
View File
@@ -1,14 +1,13 @@
@use "../variables" as *;
.theme-tab {
&.v-tabs {
.v-tab {
border-radius: $border-radius-root !important;
min-width: auto !important;
&.v-slide-group-item--active {
background: rgb(var(--v-theme-primary));
}
}
&.v-tabs {
.v-tab {
border-radius: $border-radius-root !important;
min-width: auto !important;
&.v-slide-group-item--active {
background: rgb(var(--v-theme-primary));
}
}
}
}
-1
View File
@@ -10,4 +10,3 @@
background-color: rgba(0, 0, 0, 0.025);
}
}
File diff suppressed because it is too large Load Diff
+73 -78
View File
@@ -1,104 +1,99 @@
@use "../variables" as *;
.front-lp-header {
.v-toolbar {
background: rgb(var(--v-theme-surface));
}
.v-toolbar{
background: rgb(var(--v-theme-surface));
}
&.v-app-bar .v-toolbar__content {
padding: 0;
}
&.v-app-bar .v-toolbar__content {
padding: 0;
.v-toolbar__content {
background: transparent !important;
box-shadow: none !important;
}
}
&.v-toolbar {
background: transparent !important;
top: 0 !important;
}
.v-toolbar__content {
background: transparent !important;
box-shadow: none !important;
}
&.v-toolbar {
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;
}
.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;
}
}
//
// mega menu
//
.white-btn{
background-color: #769CFF;
.white-btn {
background-color: #769cff;
}
.front_wrapper {
&.v-menu .v-overlay__content {
margin: 0 auto;
left: 0 !important;
right: 0;
&.v-menu .v-overlay__content {
margin: 0 auto;
left: 0 !important;
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 {
&::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;
}
.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;
}
}
.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;
}
}
}
}
.lp-drawer {
&.v-navigation-drawer {
top: 0 !important;
height: 100% !important;
z-index: 1007 !important;
}
&.v-navigation-drawer {
top: 0 !important;
height: 100% !important;
z-index: 1007 !important;
}
}
.lp-mobile-sidebar {
.v-list {
.v-list-item__content {
overflow: inherit;
}
}
.v-list-group__items .v-list-item {
padding-inline-start: 25px !important;
.v-list {
.v-list-item__content {
overflow: inherit;
}
}
.v-list-group__items .v-list-item {
padding-inline-start: 25px !important;
}
}
.v-btn--size-default {
&.nav-links {
font-size: $font-size-root !important;
}
&.nav-links {
font-size: $font-size-root !important;
}
}
+20 -20
View File
@@ -1,40 +1,40 @@
html {
overflow-y: auto;
overflow-y: auto;
}
.v-main{
background:rgb(var(--v-theme-background)) !important; ;
.v-main {
background: rgb(var(--v-theme-background)) !important;
}
@media (max-width: 1279px) {
.v-main {
margin: 0 10px;
}
.v-main {
margin: 0 10px;
}
}
.cursor-pointer {
cursor: pointer;
cursor: pointer;
}
.page-wrapper {
min-height: calc(100vh - 100px);
padding: 24px;
// border-radius: $border-radius-root;
@media screen and (max-width: 767px) {
padding: 20px 10px;
}
min-height: calc(100vh - 100px);
padding: 24px;
// border-radius: $border-radius-root;
@media screen and (max-width: 767px) {
padding: 20px 10px;
}
}
.maxWidth {
max-width: 1200px;
margin: 0 auto;
max-width: 1200px;
margin: 0 auto;
}
.fixed-width {
max-width: 1300px;
max-width: 1300px;
}
.right-pos-img {
position: absolute;
right: 0;
top: 0;
height: 100%;
position: absolute;
right: 0;
top: 0;
height: 100%;
}
+64 -55
View File
@@ -1,93 +1,102 @@
.v-btn.customizer-btn {
position: fixed;
bottom: 30px;
right: 30px;
border-radius: 50%;
// .icon-tabler-settings {
// animation: progress-circular-rotate 1.4s linear infinite;
// transform-origin: center center;
// transition: all 0.2s ease-in-out;
// }
position: fixed;
bottom: 30px;
right: 30px;
border-radius: 50%;
// .icon-tabler-settings {
// animation: progress-circular-rotate 1.4s linear infinite;
// transform-origin: center center;
// transition: all 0.2s ease-in-out;
// }
}
.btn-group-custom {
&.v-btn-group {
height: 66px !important;
overflow: unset !important;
.v-btn {
height: 66px !important;
padding: 0 20px;
border: 1px solid rgb(var(--v-theme-borderColor), 0.7) !important;
transition: all 0.1s ease-in 0s;
&:hover {
transform: scale(1.05);
}
&.text-primary {
.v-btn__overlay {
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;
}
&.v-btn-group {
height: 66px !important;
overflow: unset !important;
.v-btn {
height: 66px !important;
padding: 0 20px;
border: 1px solid rgb(var(--v-theme-borderColor), 0.7) !important;
transition: all 0.1s ease-in 0s;
&:hover {
transform: scale(1.05);
}
&.text-primary {
.v-btn__overlay {
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;
}
}
}
}
.hover-btns {
transition: all 0.1s ease-in 0s;
&:hover {
transform: scale(1.05);
}
transition: all 0.1s ease-in 0s;
&:hover {
transform: scale(1.05);
}
}
// all theme colors
.v-avatar.themeBlue,
.v-avatar.themeDarkBlue {
background: #1e88e5;
background: #1e88e5;
}
.v-avatar.themeAqua,
.v-avatar.themeDarkAqua {
background: #0074ba;
background: #0074ba;
}
.v-avatar.themePurple,
.v-avatar.themeDarkPurple {
background: #763ebd;
background: #763ebd;
}
.v-avatar.themeGreen,
.v-avatar.themeDarkGreen {
background: #0a7ea4;
background: #0a7ea4;
}
.v-avatar.themeCyan,
.v-avatar.themeDarkCyan {
background: #01c0c8;
background: #01c0c8;
}
.v-avatar.themeOrange,
.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 {
.togglethemeBlue {
display: block !important;
}
.togglethemeDarkBlue {
display: none !important;
}
.togglethemeDarkBlue {
display: none !important;
}
}
.BLUE_THEME, .AQUA_THEME, .ORANGE_THEME, .PURPLE_THEME, .GREEN_THEME, .CYAN_THEME {
.togglethemeDarkBlue {
display: block !important;
}
.BLUE_THEME,
.AQUA_THEME,
.ORANGE_THEME,
.PURPLE_THEME,
.GREEN_THEME,
.CYAN_THEME {
.togglethemeDarkBlue {
display: block !important;
}
.togglethemeBlue {
display: none !important;
}
.togglethemeBlue {
display: none !important;
}
}
+42 -40
View File
@@ -1,109 +1,111 @@
.h-100 {
height: 100%;
height: 100%;
}
.w-100 {
width: 100%;
width: 100%;
}
.h-100vh {
height: 100vh;
height: 100vh;
}
.gap-2 {
gap: 8px;
gap: 8px;
}
.gap-3 {
gap: 16px;
gap: 16px;
}
.gap-4 {
gap: 24px;
gap: 24px;
}
.text-white {
color: rgb(255, 255, 255) !important;
color: rgb(255, 255, 255) !important;
}
// border
.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 !important;
opacity: 1 !important;
}
.opacity-50 {
opacity: 0.5;
opacity: 0.5;
}
.z-auto.v-card {
z-index: auto;
z-index: auto;
}
.obj-cover {
object-fit: cover;
object-fit: cover;
}
.cursor-move {
cursor: move;
cursor: move;
}
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,
[contenteditable="true"] {
cursor: text;
cursor: text;
}
//Date time picker
input[type="date"],
input[type="time"] {
display: block !important;
display: block !important;
}
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator {
display: block !important;
display: block !important;
}
.ProseMirror {
min-height: 150px;
min-height: 150px;
}
.upload-btn-wrapper {
width: 150px;
height: 140px;
margin: 0 auto;
box-shadow: 0 0.5rem 1.5rem 0.5rem rgba(0, 0, 0, 0.075);
width: 150px;
height: 140px;
margin: 0 auto;
box-shadow: 0 0.5rem 1.5rem 0.5rem rgba(0, 0, 0, 0.075);
input[type=file] {
position: absolute;
left: 0;
top: 0;
opacity: 0;
height: 100%;
width: 100%;
}
input[type="file"] {
position: absolute;
left: 0;
top: 0;
opacity: 0;
height: 100%;
width: 100%;
}
}
.bg-transparent {
background-color: transparent !important;
background-color: transparent !important;
}
.bg-dark{
background-color: rgba(0, 0, 0, .08);
.bg-dark {
background-color: rgba(0, 0, 0, 0.08);
}
.bg-white-opacity{
background-color: rgba(255, 255, 255, 0.2);
.bg-white-opacity {
background-color: rgba(255, 255, 255, 0.2);
}
@media screen and (max-width:1368px) and (min-width:1200px) {
.space-20 {
padding: 30px 20px !important;
}
@media screen and (max-width: 1368px) and (min-width: 1200px) {
.space-20 {
padding: 30px 20px !important;
}
}
+313 -320
View File
@@ -1,341 +1,334 @@
.v-locale--is-rtl {
.customizer-btn {
left: 30px;
right: unset;
.customizer-btn {
left: 30px;
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 {
margin-left: 12px;
.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;
}
.bg-img-1 {
position: absolute;
bottom: 0;
left: 0;
right: unset !important;
transform: scaleX(-1);
.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);
}
.ml-1 {
margin-left: unset !important;
margin-right: 4px;
100% {
transform: translate3d(2086px, 0, 0);
}
}
@keyframes marquee2-rtl {
0% {
transform: translate3d(2086px, 0, 0);
}
.ml-2 {
margin-left: unset !important;
margin-right: 8px;
100% {
transform: translate3d(0, 0, 0);
}
}
.mr-1 {
margin-right: unset !important;
margin-left: 4px;
}
.front-wraper {
.testimonials {
.slide-counter {
left: -50px;
}
.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 {
.carousel__prev {
right: -74%;
left: unset;
right: 0;
}
.horizontal-navbar li a {
padding: 10px 13px;
}
.horizontal-navbar {
li {
margin-right: 0;
margin-left: 15px;
.rtlnav {
transform: scaleX(-1);
}
}
}
// &.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;
}
.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 {
.carousel__next {
right: -60%;
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;
}
}
+60 -60
View File
@@ -1,98 +1,98 @@
$sizes: (
'display-1': 44px,
'display-2': 40px,
'display-3': 30px,
'h1': 36px,
'h2': 30px,
'h3': 21px,
'h4': 18px,
'h5': 16px,
'h6': 14px,
'text-10': 10px,
'text-12': 12px,
'text-13': 13px,
'text-14': 14px,
'text-15': 15px,
'text-16': 16px,
'text-17': 17px,
'text-18': 18px,
'text-20': 20px,
'text-22': 22px,
'text-24': 24px,
'text-28': 28px,
'text-34': 34px,
'text-40': 40px,
'text-44': 44px,
'text-48': 48px,
'text-50': 50px,
'text-52': 52px,
'text-56': 56px,
'text-64': 64px,
'body-text-1': 10px
"display-1": 44px,
"display-2": 40px,
"display-3": 30px,
"h1": 36px,
"h2": 30px,
"h3": 21px,
"h4": 18px,
"h5": 16px,
"h6": 14px,
"text-10": 10px,
"text-12": 12px,
"text-13": 13px,
"text-14": 14px,
"text-15": 15px,
"text-16": 16px,
"text-17": 17px,
"text-18": 18px,
"text-20": 20px,
"text-22": 22px,
"text-24": 24px,
"text-28": 28px,
"text-34": 34px,
"text-40": 40px,
"text-44": 44px,
"text-48": 48px,
"text-50": 50px,
"text-52": 52px,
"text-56": 56px,
"text-64": 64px,
"body-text-1": 10px,
);
@each $pixel, $size in $sizes {
.#{$pixel} {
font-size: $size;
line-height: $size + 10;
}
.#{$pixel} {
font-size: $size;
line-height: $size + 10;
}
}
$height: (
'h-10': 10px,
'h-12': 12px,
'h-15': 15px,
"h-10": 10px,
"h-12": 12px,
"h-15": 15px,
);
@each $pixel, $size in $height {
.#{$pixel} {
height: $size;
width: $size ;
}
.#{$pixel} {
height: $size;
width: $size;
}
}
.textSecondary {
color: rgb(var(--v-theme-textSecondary)) !important;
color: rgb(var(--v-theme-textSecondary)) !important;
}
.textPrimary {
color: rgb(var(--v-theme-textPrimary)) !important;
color: rgb(var(--v-theme-textPrimary)) !important;
}
// line height
.lh-md {
line-height: 1.57;
line-height: 1.57;
}
.lh-normal{
line-height: normal;
.lh-normal {
line-height: normal;
}
.font-weight-semibold {
font-weight: 600;
font-weight: 600;
}
// hover text
.text-hover-primary {
color: rgb(var(--v-theme-textPrimary));
color: rgb(var(--v-theme-textPrimary));
&:hover {
color: rgb(var(--v-theme-primary));
}
&:hover {
color: rgb(var(--v-theme-primary));
}
}
.link {
color: rgb(var(--v-theme-textSecondary));
text-decoration: none;
color: rgb(var(--v-theme-textSecondary));
text-decoration: none;
&:hover {
color: rgb(var(--v-theme-primary));
}
&:hover {
color: rgb(var(--v-theme-primary));
}
}
.hover-primary {
&:hover {
color: rgb(var(--v-theme-primary)) !important;
opacity: 1;
}
&:hover {
color: rgb(var(--v-theme-primary)) !important;
opacity: 1;
}
}
+88 -89
View File
@@ -1,110 +1,109 @@
.v-app-bar {
.v-toolbar__content {
padding: 0 15px;
> .v-btn:first-child {
margin-inline-start: 0;
}
.v-btn {
color: rgba(var(--v-theme-textsurface)) !important;
}
.v-toolbar__content {
padding: 0 15px;
> .v-btn:first-child {
margin-inline-start: 0;
}
// .v-btn {
// color: rgba(var(--v-theme-textsurface)) !important;
// }
}
}
.custom-text-primary {
&.v-list-item:hover > .v-list-item__overlay {
display: none;
}
&.v-list-item:hover > .v-list-item__overlay {
display: none;
}
.custom-title {
color: rgb(var(--v-theme-textPrimary)) !important;
}
&:hover {
.custom-title {
color: rgb(var(--v-theme-textPrimary)) !important;
}
&:hover {
.custom-title {
color: rgb(var(--v-theme-primary)) !important;
}
color: rgb(var(--v-theme-primary)) !important;
}
}
}
@media screen and (max-width:1279px) {
.mini-sidebar {
.v-navigation-drawer.v-navigation-drawer--left {
width: 270px !important;
}
@media screen and (max-width: 1279px) {
.mini-sidebar {
.v-navigation-drawer.v-navigation-drawer--left {
width: 270px !important;
}
}
}
.notify {
position: relative;
top: -20px;
right: -8px;
position: relative;
top: -20px;
right: -8px;
.heartbit {
position: absolute;
top: -5px;
right: -2px;
height: 18px;
width: 18px;
z-index: 10;
border: 2px solid rgb(var(--v-theme-error));
border-radius: 70px;
animation: heartbit 1s ease-out;
-moz-animation: heartbit 1s ease-out;
-moz-animation-iteration-count: infinite;
-o-animation: heartbit 1s ease-out;
-o-animation-iteration-count: infinite;
-webkit-animation: heartbit 1s ease-out;
-webkit-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;
}
.heartbit {
position: absolute;
top: -5px;
right: -2px;
height: 18px;
width: 18px;
z-index: 10;
border: 2px solid rgb(var(--v-theme-error));
border-radius: 70px;
animation: heartbit 1s ease-out;
-moz-animation: heartbit 1s ease-out;
-moz-animation-iteration-count: infinite;
-o-animation: heartbit 1s ease-out;
-o-animation-iteration-count: infinite;
-webkit-animation: heartbit 1s ease-out;
-webkit-animation-iteration-count: infinite;
animation-iteration-count: infinite;
}
@keyframes heartbit {
0% {
transform: scale(0);
opacity: 0;
}
.point {
width: 4px;
height: 4px;
border-radius: 30px;
position: absolute;
right: 5px;
top: 2px;
background-color: rgb(var(--v-theme-error));
position: absolute;
}
}
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;
}
@keyframes heartbit {
0% {
transform: scale(0);
opacity: 0;
}
.v-menu.mobile_popup .v-overlay__content {
width: 100%;
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;
}
}
@media (max-width: 1199px) {
.main-head{
&.v-app-bar .v-toolbar__content{
width: 100%;
justify-content: space-between;
padding: 0 10px;
}
}
.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;
}
}
}
+161 -172
View File
@@ -1,90 +1,84 @@
.month-table {
&.custom-px-0 {
thead {
tr {
th:first-child {
padding-left: 0 !important;
}
th:last-child {
padding-right: 0 !important;
}
}
&.custom-px-0 {
thead {
tr {
th:first-child {
padding-left: 0 !important;
}
tr.month-item {
td:first-child {
padding-left: 0 !important;
}
td:last-child {
padding-right: 0 !important;
}
th:last-child {
padding-right: 0 !important;
}
}
}
tr.month-item {
td {
padding-top: 16px !important;
padding-bottom: 16px !important;
}
td:first-child {
padding-left: 0 !important;
}
&:hover {
background: transparent !important;
}
td:last-child {
padding-right: 0 !important;
}
}
}
tr.month-item {
td {
padding-top: 16px !important;
padding-bottom: 16px !important;
}
tr.month-item-0 {
td {
padding-top: 12px !important;
padding-bottom: 12px !important;
}
&:hover {
background: transparent !important;
}
}
&:hover {
background: transparent !important;
}
tr.month-item-0 {
td {
padding-top: 12px !important;
padding-bottom: 12px !important;
}
tr.month-item-hover {
&:hover {
background: transparent !important;
}
}
td {
padding-top: 12px !important;
padding-bottom: 12px !important;
}
border-left: 4px solid transparent;
&:hover {
border-left: 4px solid rgba(var(--v-theme-primary)) !important;
}
tr.month-item-hover {
td {
padding-top: 12px !important;
padding-bottom: 12px !important;
}
border-left: 4px solid transparent;
&:hover {
border-left: 4px solid rgba(var(--v-theme-primary)) !important;
}
}
}
.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 {
border-bottom: 0 !important;
}
.v-table .v-table__wrapper>table>tbody>tr>td {
padding: 12px;
}
.v-table .v-table__wrapper > table > tbody > tr > td {
padding: 12px;
}
}
.recent-transaction {
.line {
width: 2px;
height: 35px;
}
.line {
width: 2px;
height: 35px;
}
}
.chip-label {
width: 80px;
justify-content: center;
width: 80px;
justify-content: center;
}
//
@@ -92,136 +86,131 @@
//
body {
.apexcharts-tooltip {
border-radius: 16px;
}
.apexcharts-tooltip {
border-radius: 16px;
}
.apexcharts-tooltip-marker {
border-radius: 4px;
width: 12px;
height: 4px;
}
.apexcharts-tooltip-marker {
border-radius: 4px;
width: 12px;
height: 4px;
}
.apexcharts-tooltip.apexcharts-theme-dark {
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.apexcharts-theme-dark {
background: rgba(17, 28, 45, 0.8);
.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 {
.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 {
border: 1px dashed rgba(var(--v-theme-borderColor));
border-radius: 8px;
min-width: 90px;
overflow: hidden;
padding: 15px 20px;
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-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 {
gap: 16px;
}
.v-slide-group__content {
gap: 16px;
}
}
.comment-box {
border-bottom: 1px solid rgba(var(--v-theme-borderColor));
padding-bottom: 20px;
margin-bottom: 20px;
cursor: pointer;
border-bottom: 1px solid rgba(var(--v-theme-borderColor));
padding-bottom: 20px;
margin-bottom: 20px;
cursor: pointer;
&:last-child {
border-bottom: 0;
padding-bottom: 0px;
margin-bottom: 0px;
}
&:last-child {
border-bottom: 0;
padding-bottom: 0px;
margin-bottom: 0px;
}
.comment-action {
opacity: 0;
transition: 0.5s;
}
&:hover {
.comment-action {
opacity: 0;
transition: 0.5s;
}
&:hover {
.comment-action {
opacity: 1;
}
}
}
.todo-list {
border-bottom: 1px solid rgba(var(--v-theme-borderColor));
padding-bottom: 25px;
margin-bottom: 25px;
cursor: pointer;
&:last-child {
border-bottom: 0;
padding-bottom: 20px;
margin-bottom: 20px;
}
}
.progress-cards {
border-inline-end: 1px solid rgba(var(--v-theme-borderColor));
&:last-child {
border: 0;
}
@media screen and (max-width:991px) {
border-inline-end: 0 !important
}
}
.notification {
border-bottom: 1px solid rgba(var(--v-theme-borderColor));
&:last-child {
border: 0;
}
}
.earning-cards {
@media screen and (max-width:991px){
.w-25{
width: 100% !important;
&.border-e{
border: 0 !important;
}
.mobile-border{
border-bottom:1px solid rgba(var(--v-theme-borderColor));
padding-bottom: 8px !important;
}
opacity: 1;
}
}
}
.todo-list {
border-bottom: 1px solid rgba(var(--v-theme-borderColor));
padding-bottom: 25px;
margin-bottom: 25px;
cursor: pointer;
&:last-child {
border-bottom: 0;
padding-bottom: 20px;
margin-bottom: 20px;
}
}
.progress-cards {
border-inline-end: 1px solid rgba(var(--v-theme-borderColor));
&:last-child {
border: 0;
}
@media screen and (max-width: 991px) {
border-inline-end: 0 !important;
}
}
.notification {
border-bottom: 1px solid rgba(var(--v-theme-borderColor));
&:last-child {
border: 0;
}
}
.earning-cards {
@media screen and (max-width: 991px) {
.w-25 {
width: 100% !important;
&.border-e {
border: 0 !important;
}
.mobile-border {
border-bottom: 1px solid rgba(var(--v-theme-borderColor));
padding-bottom: 8px !important;
}
}
}
}
+51 -51
View File
@@ -1,61 +1,61 @@
.ProseMirror {
padding: 20px;
border: 1px solid rgb(var(--v-theme-inputBorder), 0.3);
border-radius: 0 0 12px 12px;
&.ProseMirror-focused {
outline-color: rgb(var(--v-theme-primary), 0.3) !important;
}
> * + * {
margin-top: 0.75em;
}
padding: 20px;
border: 1px solid rgb(var(--v-theme-inputBorder), 0.3);
border-radius: 0 0 12px 12px;
&.ProseMirror-focused {
outline-color: rgb(var(--v-theme-primary), 0.3) !important;
}
> * + * {
margin-top: 0.75em;
}
ul,
ol {
padding: 0 1rem;
}
ul,
ol {
padding: 0 1rem;
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
}
h1,
h2,
h3,
h4,
h5,
h6 {
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 {
background-color: rgba(#616161, 0.1);
color: #616161;
color: inherit;
padding: 0;
background: none;
font-size: 0.8rem;
}
}
pre {
background: #0d0d0d;
color: #fff;
font-family: 'JetBrainsMono', monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
img {
max-width: 100%;
height: auto;
}
code {
color: inherit;
padding: 0;
background: none;
font-size: 0.8rem;
}
}
blockquote {
padding-left: 1rem;
border-left: 2px solid rgba(#0d0d0d, 0.1);
}
img {
max-width: 100%;
height: auto;
}
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;
}
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 { DateTime } from "luxon"
import { isNil, isNull, isUndefined } from "lodash"
import currentUserApi, { UserRoles, type UserAsShow } from "@/api/current-user-api"
import { PreferenceKeys, PreferenceValueMap } from "@/api/users/preferences-api"
export { UserRoles, type UserAsShow }
@@ -35,6 +37,18 @@ export function useCurrentUser<IsLoaded extends boolean = false>() {
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> {
state.isLoading = true
try {
@@ -68,6 +82,7 @@ export function useCurrentUser<IsLoaded extends boolean = false>() {
reset,
// helpers
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 { 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)) {
return ""
} else if (typeof input == "string") {
const parsed = DateTime.fromISO(input, { zone: "utc" })
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) {
return DateTime.fromJSDate(input).toLocal().toFormat("yyyy-MM-dd")
return DateTime.fromJSDate(input).toLocal().toFormat(formatOption)
} else {
return ""
}