dsfsdfs
This commit is contained in:
242
src/api/rpc/auth.ts
Normal file
242
src/api/rpc/auth.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { getContext } from "hono/context-storage";
|
||||
import { setCookie, deleteCookie, getCookie } from 'hono/cookie';
|
||||
import { HonoVarTypes } from "types";
|
||||
import { sign, verify } from "hono/jwt";
|
||||
|
||||
interface RegisterModel {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Mock user database (in-memory)
|
||||
const mockUsers: Map<string, { password: string; user: User }> = new Map([
|
||||
['admin', {
|
||||
password: 'admin123',
|
||||
user: {
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin User'
|
||||
}
|
||||
}],
|
||||
['user@example.com', {
|
||||
password: 'password',
|
||||
user: {
|
||||
id: '2',
|
||||
username: 'user',
|
||||
email: 'user@example.com',
|
||||
name: 'Test User'
|
||||
}
|
||||
}]
|
||||
]);
|
||||
|
||||
// CSRF token storage (in-memory, in production use Redis or similar)
|
||||
const csrfTokens = new Map<string, { token: string; expires: number }>();
|
||||
|
||||
// Secret for JWT signing
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
|
||||
function generateCSRFToken(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function validateCSRFToken(sessionId: string, token: string): boolean {
|
||||
const stored = csrfTokens.get(sessionId);
|
||||
if (!stored) return false;
|
||||
if (stored.expires < Date.now()) {
|
||||
csrfTokens.delete(sessionId);
|
||||
return false;
|
||||
}
|
||||
return stored.token === token;
|
||||
}
|
||||
|
||||
const register = async (registerModel: RegisterModel) => {
|
||||
// Check if user already exists
|
||||
if (mockUsers.has(registerModel.username) || mockUsers.has(registerModel.email)) {
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
const newUser: User = {
|
||||
id: crypto.randomUUID(),
|
||||
username: registerModel.username,
|
||||
email: registerModel.email,
|
||||
name: registerModel.username
|
||||
};
|
||||
|
||||
mockUsers.set(registerModel.username, {
|
||||
password: registerModel.password,
|
||||
user: newUser
|
||||
});
|
||||
|
||||
mockUsers.set(registerModel.email, {
|
||||
password: registerModel.password,
|
||||
user: newUser
|
||||
});
|
||||
|
||||
const context = getContext<HonoVarTypes>();
|
||||
const sessionId = crypto.randomUUID();
|
||||
const csrfToken = generateCSRFToken();
|
||||
|
||||
// Store CSRF token (expires in 1 hour)
|
||||
csrfTokens.set(sessionId, {
|
||||
token: csrfToken,
|
||||
expires: Date.now() + 60 * 60 * 1000
|
||||
});
|
||||
|
||||
// Create JWT token with user info
|
||||
const token = await sign({
|
||||
sub: newUser.id,
|
||||
username: newUser.username,
|
||||
email: newUser.email,
|
||||
sessionId,
|
||||
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 24 hours
|
||||
}, JWT_SECRET);
|
||||
|
||||
// Set HTTP-only cookie
|
||||
setCookie(context, 'auth_token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'Lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 // 24 hours
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: newUser,
|
||||
csrfToken // Return CSRF token to client for subsequent requests
|
||||
};
|
||||
};
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
// Try to find user by username or email
|
||||
const userRecord = mockUsers.get(username);
|
||||
|
||||
if (!userRecord) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
if (userRecord.password !== password) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const context = getContext<HonoVarTypes>();
|
||||
const sessionId = crypto.randomUUID();
|
||||
const csrfToken = generateCSRFToken();
|
||||
|
||||
// Store CSRF token (expires in 1 hour)
|
||||
csrfTokens.set(sessionId, {
|
||||
token: csrfToken,
|
||||
expires: Date.now() + 60 * 60 * 1000
|
||||
});
|
||||
|
||||
// Create JWT token with user info
|
||||
const token = await sign({
|
||||
sub: userRecord.user.id,
|
||||
username: userRecord.user.username,
|
||||
email: userRecord.user.email,
|
||||
sessionId,
|
||||
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 24 hours
|
||||
}, JWT_SECRET);
|
||||
|
||||
// Set HTTP-only cookie
|
||||
setCookie(context, 'auth_token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'Lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 // 24 hours
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: userRecord.user,
|
||||
csrfToken // Return CSRF token to client for subsequent requests
|
||||
};
|
||||
};
|
||||
|
||||
async function checkAuth() {
|
||||
const context = getContext<HonoVarTypes>();
|
||||
const token = getCookie(context, 'auth_token');
|
||||
|
||||
if (!token) {
|
||||
return { authenticated: false, user: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await verify(token, JWT_SECRET) as any;
|
||||
|
||||
// Find user
|
||||
const userRecord = Array.from(mockUsers.values()).find(
|
||||
record => record.user.id === payload.sub
|
||||
);
|
||||
|
||||
if (!userRecord) {
|
||||
return { authenticated: false, user: null };
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
user: userRecord.user
|
||||
};
|
||||
} catch (error) {
|
||||
return { authenticated: false, user: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const context = getContext<HonoVarTypes>();
|
||||
const token = getCookie(context, 'auth_token');
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const payload = await verify(token, JWT_SECRET) as any;
|
||||
// Remove CSRF token
|
||||
if (payload.sessionId) {
|
||||
csrfTokens.delete(payload.sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
// Token invalid, just delete cookie
|
||||
}
|
||||
}
|
||||
|
||||
deleteCookie(context, 'auth_token', { path: '/' });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function getCSRFToken() {
|
||||
const context = getContext<HonoVarTypes>();
|
||||
const token = getCookie(context, 'auth_token');
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const payload = await verify(token, JWT_SECRET) as any;
|
||||
const stored = csrfTokens.get(payload.sessionId);
|
||||
|
||||
if (!stored) {
|
||||
throw new Error('CSRF token not found');
|
||||
}
|
||||
|
||||
return { csrfToken: stored.token };
|
||||
}
|
||||
|
||||
export const authMethods = {
|
||||
register,
|
||||
login,
|
||||
checkAuth,
|
||||
logout,
|
||||
getCSRFToken,
|
||||
};
|
||||
|
||||
export { validateCSRFToken };
|
||||
1
src/api/rpc/commom.ts
Normal file
1
src/api/rpc/commom.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const secret = "123_it-is-very-secret_123";
|
||||
333
src/api/rpc/index.ts
Normal file
333
src/api/rpc/index.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
exposeTinyRpc,
|
||||
httpServerAdapter,
|
||||
validateFn,
|
||||
} from "@hiogawa/tiny-rpc";
|
||||
import { tinyassert } from "@hiogawa/utils";
|
||||
import { MiddlewareHandler, type Context, type Next } from "hono";
|
||||
import { getContext } from "hono/context-storage";
|
||||
import { register } from "module";
|
||||
import { z } from "zod";
|
||||
import { authMethods } from "./auth";
|
||||
import { jwt } from "hono/jwt";
|
||||
import { secret } from "./commom";
|
||||
import { abortChunk, chunkedUpload, completeChunk, createPresignedUrls, imageContentTypes, nanoid, presignedPut, videoContentTypes } from "./s3_handle";
|
||||
// import { createElement } from "react";
|
||||
|
||||
let counter = 0;
|
||||
const listCourses = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Lập trình Web Fullstack",
|
||||
description:
|
||||
"Học cách xây dựng ứng dụng web hoàn chỉnh từ frontend đến backend. Khóa học bao gồm HTML, CSS, JavaScript, React, Node.js và MongoDB.",
|
||||
category: "Lập trình",
|
||||
rating: 4.9,
|
||||
price: "1.200.000 VNĐ",
|
||||
icon: "fas fa-code",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Web%20Fullstack",
|
||||
slug: "lap-trinh-web-fullstack",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Phân tích dữ liệu với Python",
|
||||
description:
|
||||
"Khám phá sức mạnh của Python trong việc phân tích và trực quan hóa dữ liệu. Sử dụng Pandas, NumPy, Matplotlib và Seaborn.",
|
||||
category: "Phân tích dữ liệu",
|
||||
rating: 4.8,
|
||||
price: "900.000 VNĐ",
|
||||
icon: "fas fa-chart-bar",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Data%20Analysis",
|
||||
slug: "phan-tich-du-lieu-voi-python",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Thiết kế UI/UX chuyên nghiệp",
|
||||
description:
|
||||
"Học các nguyên tắc thiết kế giao diện và trải nghiệm người dùng hiện đại. Sử dụng Figma và Adobe XD.",
|
||||
category: "Thiết kế",
|
||||
rating: 4.7,
|
||||
price: "800.000 VNĐ",
|
||||
icon: "fas fa-paint-brush",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=UI/UX%20Design",
|
||||
slug: "thiet-ke-ui-ux-chuyen-nghiep",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Machine Learning cơ bản",
|
||||
description:
|
||||
"Nhập môn Machine Learning với Python. Tìm hiểu về các thuật toán học máy cơ bản như Linear Regression, Logistic Regression, Decision Trees.",
|
||||
category: "AI/ML",
|
||||
rating: 4.6,
|
||||
price: "1.500.000 VNĐ",
|
||||
icon: "fas fa-brain",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Machine%20Learning",
|
||||
slug: "machine-learning-co-ban",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Digital Marketing toàn diện",
|
||||
description:
|
||||
"Chiến lược Marketing trên các nền tảng số. SEO, Google Ads, Facebook Ads và Content Marketing.",
|
||||
category: "Marketing",
|
||||
rating: 4.5,
|
||||
price: "700.000 VNĐ",
|
||||
icon: "fas fa-bullhorn",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Digital%20Marketing",
|
||||
slug: "digital-marketing-toan-dien",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Lập trình Mobile với Flutter",
|
||||
description:
|
||||
"Xây dựng ứng dụng di động đa nền tảng (iOS & Android) với Flutter và Dart.",
|
||||
category: "Lập trình",
|
||||
rating: 4.8,
|
||||
price: "1.100.000 VNĐ",
|
||||
icon: "fas fa-mobile-alt",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Flutter%20Mobile",
|
||||
slug: "lap-trinh-mobile-voi-flutter",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "Tiếng Anh giao tiếp công sở",
|
||||
description:
|
||||
"Cải thiện kỹ năng giao tiếp tiếng Anh trong môi trường làm việc chuyên nghiệp.",
|
||||
category: "Ngoại ngữ",
|
||||
rating: 4.4,
|
||||
price: "600.000 VNĐ",
|
||||
icon: "fas fa-language",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Business%20English",
|
||||
slug: "tieng-anh-giao-tiep-cong-so",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: "Quản trị dự án Agile/Scrum",
|
||||
description:
|
||||
"Phương pháp quản lý dự án linh hoạt Agile và khung làm việc Scrum.",
|
||||
category: "Kỹ năng mềm",
|
||||
rating: 4.7,
|
||||
price: "950.000 VNĐ",
|
||||
icon: "fas fa-tasks",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Agile%20Scrum",
|
||||
slug: "quan-tri-du-an-agile-scrum",
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
title: "Nhiếp ảnh cơ bản",
|
||||
description:
|
||||
"Làm chủ máy ảnh và nghệ thuật nhiếp ảnh. Bố cục, ánh sáng và chỉnh sửa ảnh.",
|
||||
category: "Nghệ thuật",
|
||||
rating: 4.9,
|
||||
price: "500.000 VNĐ",
|
||||
icon: "fas fa-camera",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Photography",
|
||||
slug: "nhiep-anh-co-ban",
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
title: "Blockchain 101",
|
||||
description:
|
||||
"Hiểu về công nghệ Blockchain, Bitcoin, Ethereum và Smart Contracts.",
|
||||
category: "Công nghệ",
|
||||
rating: 4.6,
|
||||
price: "1.300.000 VNĐ",
|
||||
icon: "fas fa-link",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Blockchain",
|
||||
slug: "blockchain-101",
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
title: "ReactJS Nâng cao",
|
||||
description:
|
||||
"Các kỹ thuật nâng cao trong React: Hooks, Context, Redux, Performance Optimization.",
|
||||
category: "Lập trình",
|
||||
rating: 4.9,
|
||||
price: "1.000.000 VNĐ",
|
||||
icon: "fas fa-code",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Advanced%20React",
|
||||
slug: "reactjs-nang-cao",
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
title: "Viết Content Marketing thu hút",
|
||||
description:
|
||||
"Kỹ thuật viết bài chuẩn SEO, thu hút người đọc và tăng tỷ lệ chuyển đổi.",
|
||||
category: "Marketing",
|
||||
rating: 4.5,
|
||||
price: "550.000 VNĐ",
|
||||
icon: "fas fa-pen-nib",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Content%20Marketing",
|
||||
slug: "viet-content-marketing",
|
||||
}
|
||||
];
|
||||
|
||||
const courseContent = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Giới thiệu khóa học",
|
||||
type: "video",
|
||||
duration: "5:00",
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Cài đặt môi trường",
|
||||
type: "video",
|
||||
duration: "15:00",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Kiến thức cơ bản",
|
||||
type: "video",
|
||||
duration: "25:00",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Bài tập thực hành 1",
|
||||
type: "quiz",
|
||||
duration: "10:00",
|
||||
completed: false,
|
||||
},
|
||||
];
|
||||
|
||||
const routes = {
|
||||
// define as a bare function
|
||||
checkId: (id: string) => {
|
||||
const context = getContext();
|
||||
console.log(context.req.raw.headers);
|
||||
return id === "good";
|
||||
},
|
||||
|
||||
checkIdThrow: (id: string) => {
|
||||
tinyassert(id === "good", "Invalid ID");
|
||||
return null;
|
||||
},
|
||||
|
||||
getCounter: () => {
|
||||
const context = getContext();
|
||||
console.log(context.get("jwtPayload"));
|
||||
return counter;
|
||||
},
|
||||
|
||||
// define with zod validation + input type inference
|
||||
incrementCounter: validateFn(z.object({ delta: z.number().default(1) }))(
|
||||
(input) => {
|
||||
// expectTypeOf(input).toEqualTypeOf<{ delta: number }>();
|
||||
counter += input.delta;
|
||||
return counter;
|
||||
}
|
||||
),
|
||||
|
||||
// access context
|
||||
components: async () => {},
|
||||
getHomeCourses: async () => {
|
||||
return listCourses.slice(0, 3);
|
||||
},
|
||||
getCourses: validateFn(
|
||||
z.object({
|
||||
page: z.number().default(1),
|
||||
limit: z.number().default(6),
|
||||
search: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
})
|
||||
)(async ({ page, limit, search, category }) => {
|
||||
let filtered = listCourses;
|
||||
|
||||
if (search) {
|
||||
const lowerSearch = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(lowerSearch) ||
|
||||
c.description.toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
}
|
||||
|
||||
if (category && category !== "All") {
|
||||
filtered = filtered.filter((c) => c.category === category);
|
||||
}
|
||||
|
||||
const start = (page - 1) * limit;
|
||||
const end = start + limit;
|
||||
const paginated = filtered.slice(start, end);
|
||||
|
||||
return {
|
||||
data: paginated,
|
||||
total: filtered.length,
|
||||
page,
|
||||
totalPages: Math.ceil(filtered.length / limit),
|
||||
};
|
||||
}),
|
||||
getCourseBySlug: validateFn(z.object({ slug: z.string() }))(async ({ slug }) => {
|
||||
const course = listCourses.find((c) => c.slug === slug);
|
||||
if (!course) {
|
||||
throw new Error("Course not found");
|
||||
}
|
||||
return course;
|
||||
}),
|
||||
getCourseContent: validateFn(z.object({ slug: z.string() }))(async ({ slug }) => {
|
||||
// In a real app, we would fetch content specific to the course
|
||||
return courseContent;
|
||||
}),
|
||||
presignedPut: validateFn(z.object({ fileName: z.string(), contentType: z.string().refine((val) => imageContentTypes.includes(val), { message: "Invalid content type" }) }))(async ({ fileName, contentType }) => {
|
||||
return await presignedPut(fileName, contentType);
|
||||
}),
|
||||
chunkedUpload: validateFn(z.object({ fileName: z.string(), contentType: z.string().refine((val) => videoContentTypes.includes(val), { message: "Invalid content type" }), fileSize: z.number().min(1024 * 10).max(3 * 1024 * 1024 * 1024).default(1024 * 256) }))(async ({ fileName, contentType, fileSize }) => {
|
||||
const key = nanoid() + "_" + fileName;
|
||||
const { UploadId } = await chunkedUpload(key, contentType, fileSize);
|
||||
const chunkSize = 1024 * 1024 * 20; // 20MB
|
||||
const presignedUrls = await createPresignedUrls({
|
||||
key,
|
||||
uploadId: UploadId!,
|
||||
totalParts: Math.ceil(fileSize / chunkSize),
|
||||
});
|
||||
return { uploadId: UploadId!, presignedUrls, chunkSize, key, totalParts: presignedUrls.length };
|
||||
}),
|
||||
completeChunk: validateFn(z.object({ key: z.string(), uploadId: z.string(), parts: z.array(z.object({ PartNumber: z.number(), ETag: z.string() })) }))(async ({ key, uploadId, parts }) => {
|
||||
await completeChunk(key, uploadId, parts);
|
||||
return { success: true };
|
||||
}),
|
||||
abortChunk: validateFn(z.object({ key: z.string(), uploadId: z.string() }))(async ({ key, uploadId }) => {
|
||||
await abortChunk(key, uploadId);
|
||||
return { success: true };
|
||||
}),
|
||||
...authMethods
|
||||
};
|
||||
export type RpcRoutes = typeof routes;
|
||||
export const endpoint = "/rpc";
|
||||
export const pathsForGET: (keyof typeof routes)[] = ["getCounter"];
|
||||
export const jwtRpc: MiddlewareHandler = async (c, next) => {
|
||||
const publicPaths: (keyof typeof routes)[] = ["getHomeCourses", "getCourses", "getCourseBySlug", "getCourseContent", "login", "register"];
|
||||
const isPublic = publicPaths.some((path) => c.req.path.split("/").includes(path));
|
||||
c.set("isPublic", isPublic);
|
||||
// return await next();
|
||||
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/") || isPublic) {
|
||||
return await next();
|
||||
}
|
||||
console.log("JWT RPC Middleware:", c.req.path);
|
||||
const jwtMiddleware = jwt({
|
||||
secret,
|
||||
cookie: 'auth_token',
|
||||
verification: {
|
||||
aud: "ez.lms_users",
|
||||
}
|
||||
})
|
||||
return jwtMiddleware(c, next)
|
||||
}
|
||||
export const rpcServer = async (c: Context, next: Next) => {
|
||||
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/")) {
|
||||
return await next();
|
||||
}
|
||||
// c.get("redis").has(`auth_token:${}`)
|
||||
const handler = exposeTinyRpc({
|
||||
routes,
|
||||
adapter: httpServerAdapter({ endpoint }),
|
||||
});
|
||||
const res = await handler({ request: c.req.raw });
|
||||
if (res) {
|
||||
return res;
|
||||
}
|
||||
return await next();
|
||||
};
|
||||
198
src/api/rpc/s3_handle.ts
Normal file
198
src/api/rpc/s3_handle.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
S3Client,
|
||||
ListBucketsCommand,
|
||||
ListObjectsV2Command,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
UploadPartCommand,
|
||||
AbortMultipartUploadCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
ListPartsCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
|
||||
import { randomBytes } from "crypto";
|
||||
const urlAlphabet = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict';
|
||||
|
||||
export function nanoid(size = 21) {
|
||||
let id = '';
|
||||
const bytes = randomBytes(size); // Node.js specific method
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
id += urlAlphabet[bytes[i] & 63];
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
// createPresignedPost
|
||||
const S3 = new S3Client({
|
||||
region: "auto", // Required by SDK but not used by R2
|
||||
endpoint: `https://s3.cloudfly.vn`,
|
||||
credentials: {
|
||||
// accessKeyId: "Q3AM3UQ867SPQQA43P2F",
|
||||
// secretAccessKey: "Ik7nlCaUUCFOKDJAeSgFcbF5MEBGh9sVGBUrsUOp",
|
||||
accessKeyId: "BD707P5W8J5DHFPUKYZ6",
|
||||
secretAccessKey: "LTX7IizSDn28XGeQaHNID2fOtagfLc6L2henrP6P",
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
// const S3 = new S3Client({
|
||||
// region: "auto", // Required by SDK but not used by R2
|
||||
// endpoint: `https://u.pipic.fun`,
|
||||
// credentials: {
|
||||
// // accessKeyId: "Q3AM3UQ867SPQQA43P2F",
|
||||
// // secretAccessKey: "Ik7nlCaUUCFOKDJAeSgFcbF5MEBGh9sVGBUrsUOp",
|
||||
// accessKeyId: "cdnadmin",
|
||||
// secretAccessKey: "D@tkhong9",
|
||||
// },
|
||||
// forcePathStyle: true,
|
||||
// });
|
||||
export const imageContentTypes = ["image/png", "image/jpg", "image/jpeg", "image/webp"];
|
||||
export const videoContentTypes = ["video/mp4", "video/webm", "video/ogg", "video/*"];
|
||||
const nanoId = () => {
|
||||
// return crypto.randomUUID().replace(/-/g, "").slice(0, 10);
|
||||
return ""
|
||||
}
|
||||
export async function presignedPut(fileName: string, contentType: string){
|
||||
if (!imageContentTypes.includes(contentType)) {
|
||||
throw new Error("Invalid content type");
|
||||
}
|
||||
const key = nanoId()+"_"+fileName;
|
||||
const url = await getSignedUrl(
|
||||
S3,
|
||||
new PutObjectCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
CacheControl: "public, max-age=31536000, immutable",
|
||||
// ContentLength: 31457280, // Max 30MB
|
||||
// ACL: "public-read", // Uncomment if you want the object to be publicly readable
|
||||
}),
|
||||
{ expiresIn: 600 } // URL valid for 10 minutes
|
||||
);
|
||||
return { url, key };
|
||||
}
|
||||
export async function createPresignedUrls({
|
||||
key,
|
||||
uploadId,
|
||||
totalParts,
|
||||
expiresIn = 60 * 15, // 15 phút
|
||||
}: {
|
||||
key: string;
|
||||
uploadId: string;
|
||||
totalParts: number;
|
||||
expiresIn?: number;
|
||||
}) {
|
||||
const urls = [];
|
||||
|
||||
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
|
||||
const command = new UploadPartCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
PartNumber: partNumber,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(S3, command, {
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
urls.push({
|
||||
partNumber,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
export async function chunkedUpload(Key: string, contentType: string, fileSize: number) {
|
||||
// lớn hơn 3gb thì cút
|
||||
if (fileSize > 3 * 1024 * 1024 * 1024) {
|
||||
throw new Error("File size exceeds 3GB");
|
||||
}
|
||||
// CreateMultipartUploadCommand
|
||||
const uploadParams = {
|
||||
Bucket: "tmp",
|
||||
Key,
|
||||
ContentType: contentType,
|
||||
CacheControl: "public, max-age=31536000, immutable",
|
||||
};
|
||||
let data = await S3.send(new CreateMultipartUploadCommand(uploadParams));
|
||||
return data;
|
||||
}
|
||||
export async function abortChunk(key: string, uploadId: string) {
|
||||
await S3.send(
|
||||
new AbortMultipartUploadCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
})
|
||||
);
|
||||
}
|
||||
export async function completeChunk(key: string, uploadId: string, parts: { ETag: string; PartNumber: number }[]) {
|
||||
const listed = await S3.send(
|
||||
new ListPartsCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
})
|
||||
);
|
||||
if (!listed.Parts || listed.Parts.length !== parts.length) {
|
||||
throw new Error("Not all parts have been uploaded");
|
||||
}
|
||||
await S3.send(
|
||||
new CompleteMultipartUploadCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
MultipartUpload: {
|
||||
Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
export async function deleteObject(bucketName: string, objectKey: string) {
|
||||
await S3.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: objectKey,
|
||||
})
|
||||
);
|
||||
}
|
||||
export async function listBuckets() {
|
||||
const data = await S3.send(new ListBucketsCommand({}));
|
||||
return data.Buckets;
|
||||
}
|
||||
export async function listObjects(bucketName: string) {
|
||||
const data = await S3.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucketName,
|
||||
})
|
||||
);
|
||||
return data.Contents;
|
||||
}
|
||||
export async function generateUploadForm(fileName: string, contentType: string) {
|
||||
if (!imageContentTypes.includes(contentType)) {
|
||||
throw new Error("Invalid content type");
|
||||
}
|
||||
return await createPresignedPost(S3, {
|
||||
Bucket: "tmp",
|
||||
Key: nanoId()+"_"+fileName,
|
||||
Expires: 10 * 60, // URL valid for 10 minutes
|
||||
Conditions: [
|
||||
["starts-with", "$Content-Type", contentType],
|
||||
["content-length-range", 0, 31457280], // Max 30MB
|
||||
],
|
||||
});
|
||||
}
|
||||
// generateUploadUrl("tmp", "cat.png", "image/png").then(console.log);
|
||||
export async function createDownloadUrl(key: string): Promise<string> {
|
||||
const url = await getSignedUrl(
|
||||
S3,
|
||||
new GetObjectCommand({ Bucket: "tmp", Key: key }),
|
||||
{ expiresIn: 600 } // 600 giây = 10 phút
|
||||
);
|
||||
return url;
|
||||
}
|
||||
79
src/api/rpcclient.ts
Normal file
79
src/api/rpcclient.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
proxyTinyRpc,
|
||||
TinyRpcClientAdapter,
|
||||
TinyRpcError,
|
||||
} from "@hiogawa/tiny-rpc";
|
||||
import type { RpcRoutes } from "./rpc";
|
||||
import { Result } from "@hiogawa/utils";
|
||||
declare let __host__: string;
|
||||
const endpoint = "/rpc";
|
||||
const url = import.meta.env.SSR ? "http://localhost" : "";
|
||||
const headers: Record<string, string> = {}; // inject headers to demonstrate context
|
||||
export const client = proxyTinyRpc<RpcRoutes>({
|
||||
adapter: httpClientAdapter({
|
||||
url: url + endpoint,
|
||||
pathsForGET: [],
|
||||
}),
|
||||
});
|
||||
const GET_PAYLOAD_PARAM = "payload";
|
||||
function httpClientAdapter(opts: {
|
||||
url: string;
|
||||
pathsForGET?: string[];
|
||||
}): TinyRpcClientAdapter {
|
||||
return {
|
||||
send: async (data) => {
|
||||
const url = [opts.url, data.path].join("/");
|
||||
const payload = JSON.stringify(data.args);
|
||||
const method = opts.pathsForGET?.includes(data.path)
|
||||
? "GET"
|
||||
: "POST";
|
||||
let req: Request;
|
||||
if (method === "GET") {
|
||||
req = new Request(
|
||||
url +
|
||||
"?" +
|
||||
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload })
|
||||
);
|
||||
} else {
|
||||
req = new Request(url, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
},
|
||||
credentials: "include",
|
||||
});
|
||||
}
|
||||
let res: Response;
|
||||
if (import.meta.env.SSR) {
|
||||
const { getContext } = await import("hono/context-storage");
|
||||
const c = getContext<any>();
|
||||
Object.entries(c.req.header()).forEach(([k, v]) => {
|
||||
req.headers.append(k, v);
|
||||
});
|
||||
res = await c.get("fetch")(req);
|
||||
} else {
|
||||
res = await fetch(req);
|
||||
}
|
||||
if (!res.ok) {
|
||||
// throw new Error(`HTTP error: ${res.status}`);
|
||||
throw new Error(
|
||||
JSON.stringify({
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
data: { message: await res.text() },
|
||||
internal: true,
|
||||
})
|
||||
);
|
||||
// throw TinyRpcError.deserialize(res.status);
|
||||
}
|
||||
const result: Result<unknown, unknown> = JSON.parse(
|
||||
await res.text()
|
||||
);
|
||||
if (!result.ok) {
|
||||
throw TinyRpcError.deserialize(result.value);
|
||||
}
|
||||
return result.value;
|
||||
},
|
||||
};
|
||||
}
|
||||
11
src/client.ts
Normal file
11
src/client.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createApp } from './main';
|
||||
import 'uno.css';
|
||||
async function render() {
|
||||
const { app, router } = createApp();
|
||||
router.isReady().then(() => {
|
||||
app.mount('body', true)
|
||||
})
|
||||
}
|
||||
render().catch((error) => {
|
||||
console.error('Error during app initialization:', error)
|
||||
})
|
||||
59
src/components/DashboardLayout.vue
Normal file
59
src/components/DashboardLayout.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script lang="ts" setup>
|
||||
import { Search } from "@/components/icons";
|
||||
import Home from "@/components/icons/Home.vue";
|
||||
import HomeFilled from "@/components/icons/HomeFilled.vue";
|
||||
import Layout from "@/components/icons/Layout.vue";
|
||||
import LayoutFilled from "@/components/icons/LayoutFilled.vue";
|
||||
import { createStaticVNode, inject, Ref, watch } from "vue";
|
||||
import Add from "@/components/icons/Add.vue";
|
||||
import AddFilled from "@/components/icons/AddFilled.vue";
|
||||
import Bell from "@/components/icons/Bell.vue";
|
||||
import BellFilled from "@/components/icons/BellFilled.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const className = ":uno: w-12 h-12 p-2 rounded-2xl hover:bg-primary/10 flex press-animated"
|
||||
const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />`, 1);
|
||||
const links = [
|
||||
{ href: "/", label: "app", icon: homeHoist, exact: homeHoist, type: "a", exactClass: "" },
|
||||
{ href: "/", label: "Home", icon: Home, exact: HomeFilled, type: "a", exactClass: 'bg-primary/10' },
|
||||
{ href: "/search", label: "Search", icon: Search, exact: Search, type: "btn", exactClass: "" },
|
||||
{ href: "/video", label: "Video", icon: Layout, exact: LayoutFilled, type: "a", exactClass: 'bg-primary/10' },
|
||||
{ href: "/add", label: "Add", icon: Add, exact: AddFilled, type: "a", exactClass: 'bg-primary/10' },
|
||||
{ href: "/notification", label: "Notification", icon: Bell, exact: BellFilled, type: "a", exactClass: 'bg-primary/10' },
|
||||
];
|
||||
</script>
|
||||
<template>
|
||||
<div class="fixed left-0 w-18 flex flex-col items-center pt-4 gap-6 z-41">
|
||||
<template v-for="i in links" :key="i.label">
|
||||
<router-link v-if="i.type === 'a'" v-tooltip="i.label" :exact-active-class="i.exactClass" :to="i.href"
|
||||
v-slot="{ isExactActive }" :class="className">
|
||||
<component :is="isExactActive ? i.exact : i.icon" />
|
||||
</router-link>
|
||||
<div v-else :class="className" v-tooltip="i.label">
|
||||
<component :is="i.icon" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-12 h-12 rounded-2xl hover:bg-primary/10 flex">
|
||||
<button class="h-[38px] w-[38px] rounded-full m-a ring-2 ring flex press-animated" @click="auth.logout()">
|
||||
<img class="h-8 w-8 rounded-full m-a ring-1 ring-white"
|
||||
src="https://picsum.photos/seed/user123/40/40.jpg" alt="User avatar" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<main class="flex flex-1 overflow-hidden md:ps-18">
|
||||
<div class="flex-1 overflow-auto p-4 bg-white rounded-lg md:(mr-2 mb-2) min-h-[calc(100vh-8rem)]">
|
||||
<router-view v-slot="{ Component }">
|
||||
<Transition enter-active-class="transition-all duration-300 ease-in-out"
|
||||
enter-from-class="opacity-0 transform translate-y-4"
|
||||
enter-to-class="opacity-100 transform translate-y-0"
|
||||
leave-active-class="transition-all duration-200 ease-in-out"
|
||||
leave-from-class="opacity-100 transform translate-y-0"
|
||||
leave-to-class="opacity-0 transform -translate-y-4" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</Transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
3
src/components/RootLayout.vue
Normal file
3
src/components/RootLayout.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
7
src/components/icons/Add.vue
Normal file
7
src/components/icons/Add.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="v-mid m-a" height="24" viewBox="-10 -226 468 468">
|
||||
<path
|
||||
d="M64-184c-18 0-32 14-32 32v320c0 18 14 32 32 32h320c18 0 32-14 32-32v-320c0-18-14-32-32-32H64zM0-152c0-35 29-64 64-64h320c35 0 64 29 64 64v320c0 35-29 64-64 64H64c-35 0-64-29-64-64v-320zm208 256V24h-80c-9 0-16-7-16-16s7-16 16-16h80v-80c0-9 7-16 16-16s16 7 16 16v80h80c9 0 16 7 16 16s-7 16-16 16h-80v80c0 9-7 16-16 16s-16-7-16-16z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
</template>
|
||||
10
src/components/icons/AddFilled.vue
Normal file
10
src/components/icons/AddFilled.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 468 468" width="24">
|
||||
<path
|
||||
d="M42 74v320c0 18 14 32 32 32h320c18 0 32-14 32-32V74c0-18-14-32-32-32H74c-18 0-32 14-32 32zm80 160c0-9 7-16 16-16h80v-80c0-9 7-16 16-16s16 7 16 16v80h80c9 0 16 7 16 16s-7 16-16 16h-80v80c0 9-7 16-16 16s-16-7-16-16v-80h-80c-9 0-16-7-16-16z"
|
||||
fill="#a6acb9" />
|
||||
<path
|
||||
d="M74 42c-18 0-32 14-32 32v320c0 18 14 32 32 32h320c18 0 32-14 32-32V74c0-18-14-32-32-32H74zM10 74c0-35 29-64 64-64h320c35 0 64 29 64 64v320c0 35-29 64-64 64H74c-35 0-64-29-64-64V74zm208 256v-80h-80c-9 0-16-7-16-16s7-16 16-16h80v-80c0-9 7-16 16-16s16 7 16 16v80h80c9 0 16 7 16 16s-7 16-16 16h-80v80c0 9-7 16-16 16s-16-7-16-16z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
</template>
|
||||
3
src/components/icons/Bell.vue
Normal file
3
src/components/icons/Bell.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="v-mid m-a" height="24" viewBox="-10 -258 468 532"><path d="M224-248c-13 0-24 11-24 24v10C119-203 56-133 56-48v15C56 4 46 41 27 74L5 111c-3 6-5 13-5 19 0 21 17 38 38 38h372c21 0 38-17 38-38 0-6-2-13-5-19l-22-37c-19-33-29-70-29-108v-14c0-85-63-155-144-166v-10c0-13-11-24-24-24zm168 368H56l12-22c24-40 36-85 36-131v-15c0-66 54-120 120-120s120 54 120 120v15c0 46 12 91 36 131l12 22zm-236 96c10 28 37 48 68 48s58-20 68-48H156z" fill="#1e3050"/></svg>
|
||||
</template>
|
||||
3
src/components/icons/BellFilled.vue
Normal file
3
src/components/icons/BellFilled.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="v-mid m-a" height="24" width="24" viewBox="0 0 468 532"><path d="M66 378h337l-13-22c-24-40-36-85-36-131v-15c0-66-54-120-120-120s-120 54-120 120v15c0 46-12 91-35 131l-13 22z" fill="#a6acb9"/><path d="M234 10c-13 0-24 11-24 24v10C129 55 66 125 66 210v15c0 37-10 74-29 107l-22 37c-3 6-5 13-5 19 0 21 17 38 38 38h372c21 0 38-17 38-38 0-6-2-13-5-19l-22-37c-19-33-29-70-29-108v-14c0-85-63-155-144-166V34c0-13-11-24-24-24zm168 368H66l12-22c24-40 36-85 36-131v-15c0-66 54-120 120-120s120 54 120 120v15c0 46 12 91 36 131l12 22zm-236 96c10 28 37 48 68 48s58-20 68-48H166z" fill="#1e3050"/></svg>
|
||||
</template>
|
||||
3
src/components/icons/Home.vue
Normal file
3
src/components/icons/Home.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="v-mid m-a" height="24" width="24" viewBox="-11 -259 535 533"><path d="M272-242c-9-8-23-8-32 0L8-34C-2-25-3-10 6 0s24 11 34 2l8-7v205c0 35 29 64 64 64h288c35 0 64-29 64-64V-5l8 7c10 9 25 8 34-2s8-25-2-34L272-242zM416-48v248c0 9-7 16-16 16H112c-9 0-16-7-16-16V-48l160-144L416-48z" fill="#1e3050"/></svg>
|
||||
</template>
|
||||
3
src/components/icons/HomeFilled.vue
Normal file
3
src/components/icons/HomeFilled.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="v-mid m-a" height="24" width="24" viewBox="0 0 539 535"><path d="M61 281c2-1 4-3 6-5L269 89l202 187c2 2 4 4 6 5v180c0 35-29 64-64 64H125c-35 0-64-29-64-64V281z" fill="#a6acb9"/><path d="M247 22c13-12 32-12 44 0l224 208c13 12 13 32 1 45s-32 14-45 2L269 89 67 276c-13 12-33 12-45-1s-12-33 1-45L247 22z" fill="#1e3050"/></svg>
|
||||
</template>
|
||||
3
src/components/icons/Layout.vue
Normal file
3
src/components/icons/Layout.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="v-mid m-a" height="24" width="24" viewBox="-10 -226 468 468"><path d="M384-184c18 0 32 14 32 32v64H32v-64c0-18 14-32 32-32h320zM32 168V-56h96v256H64c-18 0-32-14-32-32zm128 32V-56h256v224c0 18-14 32-32 32H160zM64-216c-35 0-64 29-64 64v320c0 35 29 64 64 64h320c35 0 64-29 64-64v-320c0-35-29-64-64-64H64z" fill="#1e3050"/></svg>
|
||||
</template>
|
||||
3
src/components/icons/LayoutFilled.vue
Normal file
3
src/components/icons/LayoutFilled.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="v-mid m-a" height="24" width="24" viewBox="0 0 468 468"><path d="M42 74v64h384V74c0-18-14-32-32-32H74c-18 0-32 14-32 32zm0 96v224c0 18 14 32 32 32h64V170H42zm128 0v256h224c18 0 32-14 32-32V170H170z" fill="#a6acb9"/><path d="M394 42c18 0 32 14 32 32v64H42V74c0-18 14-32 32-32h320zM42 394V170h96v256H74c-18 0-32-14-32-32zm128 32V170h256v224c0 18-14 32-32 32H170zM74 10c-35 0-64 29-64 64v320c0 35 29 64 64 64h320c35 0 64-29 64-64V74c0-35-29-64-64-64H74z" fill="#1e3050"/></svg>
|
||||
</template>
|
||||
7
src/components/icons/TestIcon.vue
Normal file
7
src/components/icons/TestIcon.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M20.54 14.24A3.15 3.15 0 0 0 23.66 17H24v2h-8v1h-.02a3.4 3.4 0 0 1-3.38 3h-1.2a3.4 3.4 0 0 1-3.38-3H8v-1H0v-2h.34a3.15 3.15 0 0 0 3.12-2.76l.8-6.41a7.8 7.8 0 0 1 15.48 0zM10 19.6c0 .77.63 1.4 1.4 1.4h1.2c.77 0 1.4-.63 1.4-1.4a.6.6 0 0 0-.6-.6h-2.8a.6.6 0 0 0-.6.6">
|
||||
</path>
|
||||
</svg>
|
||||
</template>
|
||||
46
src/components/icons/index.ts
Normal file
46
src/components/icons/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createStaticVNode } from "vue";
|
||||
|
||||
export const Home = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M4.6 22.73A107 107 0 0 0 11 23h2.22c2.43-.04 4.6-.16 6.18-.27A3.9 3.9 0 0 0 23 18.8v-8.46a4 4 0 0 0-1.34-3L14.4.93a3.63 3.63 0 0 0-4.82 0L2.34 7.36A4 4 0 0 0 1 10.35v8.46a3.9 3.9 0 0 0 3.6 3.92M13.08 2.4l7.25 6.44a2 2 0 0 1 .67 1.5v8.46a1.9 1.9 0 0 1-1.74 1.92q-1.39.11-3.26.19V16a4 4 0 0 0-8 0v4.92q-1.87-.08-3.26-.19A1.9 1.9 0 0 1 3 18.81v-8.46a2 2 0 0 1 .67-1.5l7.25-6.44a1.63 1.63 0 0 1 2.16 0M13.12 21h-2.24a1 1 0 0 1-.88-1v-4a2 2 0 1 1 4 0v4a1 1 0 0 1-.88 1">
|
||||
</path>
|
||||
</svg>`, 1);
|
||||
export const HomeFilled = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M9.59.92a3.63 3.63 0 0 1 4.82 0l7.25 6.44A4 4 0 0 1 23 10.35v8.46a3.9 3.9 0 0 1-3.6 3.92 106 106 0 0 1-14.8 0A3.9 3.9 0 0 1 1 18.8v-8.46a4 4 0 0 1 1.34-3zM12 16a5 5 0 0 1-3.05-1.04l-1.23 1.58a7 7 0 0 0 8.56 0l-1.23-1.58A5 5 0 0 1 12 16">
|
||||
</path>
|
||||
</svg>`, 1);
|
||||
export const Dashboard = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M23 5a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v14a4 4 0 0 0 4 4h14a4 4 0 0 0 4-4zm-10 6V3h6a2 2 0 0 1 2 2v6zm8 8a2 2 0 0 1-2 2h-6v-8h8zM5 3h6v18H5a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2">
|
||||
</path>
|
||||
</svg>`, 1);
|
||||
export const DashboardFilled = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M11 23H5a4 4 0 0 1-4-4V5a4 4 0 0 1 4-4h6zm12-4a4 4 0 0 1-4 4h-6V13h10zM19 1a4 4 0 0 1 4 4v6H13V1z">
|
||||
</path>
|
||||
</svg>`, 1);
|
||||
export const Add = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M11 11H6v2h5v5h2v-5h5v-2h-5V6h-2zM5 1a4 4 0 0 0-4 4v14a4 4 0 0 0 4 4h14a4 4 0 0 0 4-4V5a4 4 0 0 0-4-4zm16 4v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h14a2 2 0 0 1 2 2">
|
||||
</path>
|
||||
</svg>`, 1);
|
||||
export const AddFilled = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M1 5a4 4 0 0 1 4-4h14a4 4 0 0 1 4 4v14a4 4 0 0 1-4 4H5a4 4 0 0 1-4-4zm10 6H6v2h5v5h2v-5h5v-2h-5V6h-2z">
|
||||
</path>
|
||||
</svg>`, 1);
|
||||
export const Bell = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M16 19h8v-2h-.34a3.15 3.15 0 0 1-3.12-2.76l-.8-6.41a7.8 7.8 0 0 0-15.48 0l-.8 6.41A3.15 3.15 0 0 1 .34 17H0v2h8v1h.02a3.4 3.4 0 0 0 3.38 3h1.2a3.4 3.4 0 0 0 3.38-3H16zm1.75-10.92.8 6.4c.12.95.5 1.81 1.04 2.52H4.4c.55-.7.92-1.57 1.04-2.51l.8-6.41a5.8 5.8 0 0 1 11.5 0M13.4 19c.33 0 .6.27.6.6 0 .77-.63 1.4-1.4 1.4h-1.2a1.4 1.4 0 0 1-1.4-1.4c0-.33.27-.6.6-.6z">
|
||||
</path>
|
||||
</svg>`, 1);
|
||||
export const BellFilled = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M20.54 14.24A3.15 3.15 0 0 0 23.66 17H24v2h-8v1h-.02a3.4 3.4 0 0 1-3.38 3h-1.2a3.4 3.4 0 0 1-3.38-3H8v-1H0v-2h.34a3.15 3.15 0 0 0 3.12-2.76l.8-6.41a7.8 7.8 0 0 1 15.48 0zM10 19.6c0 .77.63 1.4 1.4 1.4h1.2c.77 0 1.4-.63 1.4-1.4a.6.6 0 0 0-.6-.6h-2.8a.6.6 0 0 0-.6.6" ></path>
|
||||
</svg>`, 1);
|
||||
export const Search = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M17.33 18.74a10 10 0 1 1 1.41-1.41l4.47 4.47-1.41 1.41zM11 3a8 8 0 1 0 0 16 8 8 0 0 0 0-16">
|
||||
</path>
|
||||
</svg>`, 1);
|
||||
@@ -1,18 +1,30 @@
|
||||
import { Hono } from 'hono'
|
||||
import { renderer } from './renderer'
|
||||
import { createApp } from './main';
|
||||
import { renderToWebStream } from 'vue/server-renderer';
|
||||
import { streamText } from 'hono/streaming';
|
||||
import { renderSSRHead } from '@unhead/vue/server';
|
||||
import { buildBootstrapScript, getHrefFromManifest } from './lib/manifest';
|
||||
import { contextStorage } from 'hono/context-storage';
|
||||
import { cors } from "hono/cors";
|
||||
import { jwtRpc, rpcServer } from './api/rpc';
|
||||
import isMobile from 'is-mobile';
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
// app.use(renderer)
|
||||
|
||||
// app.get('/', (c) => {
|
||||
// return c.text('Hello World!')
|
||||
// // return c.render(<h1>Hello!</h1>)
|
||||
// })
|
||||
app.use(cors(), async (c, next) => {
|
||||
c.set("fetch", app.request.bind(app));
|
||||
const ua = c.req.header("User-Agent")
|
||||
if (!ua) {
|
||||
return c.json({ error: "User-Agent header is missing" }, 400);
|
||||
};
|
||||
c.set("isMobile", isMobile({ ua }));
|
||||
await next();
|
||||
}, contextStorage(), rpcServer);
|
||||
app.get("/.well-known/*", (c) => {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
app.get("*", async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
const { app, router, head } = createApp();
|
||||
@@ -26,27 +38,28 @@ app.get("*", async (c) => {
|
||||
await stream.write("<!DOCTYPE html><html lang='en'><head>");
|
||||
await stream.write("<base href='" + url.origin + "'/>");
|
||||
await renderSSRHead(head).then((headString) => stream.write(headString.headTags.replace(/\n/g, "")));
|
||||
await stream.write(`<link
|
||||
href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"rel="stylesheet"></link>`);
|
||||
await stream.write("</head><body class='font-sans bg-[#f9fafd] text-gray-800 antialiased flex flex-col'>");
|
||||
await stream.pipe(appStream);
|
||||
let json = htmlEscape(JSON.stringify(JSON.stringify(ctx)));
|
||||
await stream.write(`<script>window.__SSR_STATE__ = JSON.parse(${json});</script>`);
|
||||
await stream.write("</body></html>");
|
||||
await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"rel="stylesheet"></link>`);
|
||||
await stream.write('<link rel="icon" href="/favicon.ico" />');
|
||||
await stream.write(buildBootstrapScript());
|
||||
await stream.write("</head><body class='font-sans bg-[#f9fafd] text-gray-800 antialiased flex flex-col min-h-screen'>");
|
||||
await stream.pipe(appStream);
|
||||
let json = htmlEscape(JSON.stringify(JSON.stringify(ctx)));
|
||||
await stream.write(`<script>window.__SSR_STATE__ = JSON.parse(${json});</script>`);
|
||||
await stream.write("</body></html>");
|
||||
});
|
||||
// return c.body(renderToWebStream(app, {}));
|
||||
})
|
||||
const ESCAPE_LOOKUP: { [match: string]: string } = {
|
||||
"&": "\\u0026",
|
||||
">": "\\u003e",
|
||||
"<": "\\u003c",
|
||||
"\u2028": "\\u2028",
|
||||
"\u2029": "\\u2029",
|
||||
"&": "\\u0026",
|
||||
">": "\\u003e",
|
||||
"<": "\\u003c",
|
||||
"\u2028": "\\u2028",
|
||||
"\u2029": "\\u2029",
|
||||
};
|
||||
|
||||
const ESCAPE_REGEX = /[&><\u2028\u2029]/g;
|
||||
|
||||
function htmlEscape(str: string): string {
|
||||
return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
|
||||
return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
|
||||
}
|
||||
export default app
|
||||
|
||||
3
src/lib/constants.ts
Normal file
3
src/lib/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const requestCtxKey = Symbol("requestCtx");
|
||||
export const isRouteLoading = Symbol("isRouteLoading");
|
||||
export const routerKey = Symbol("routerKey");
|
||||
94
src/lib/manifest.ts
Normal file
94
src/lib/manifest.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { Manifest } from "vite";
|
||||
export interface GetHrefOptions {
|
||||
href: string;
|
||||
manifest?: Manifest;
|
||||
prod?: boolean;
|
||||
baseUrl?: string;
|
||||
}
|
||||
// Use Vite's import.meta.glob to dynamically search for manifest.json
|
||||
export const loadManifest = (): Manifest | undefined => {
|
||||
// Check if manifest content is provided via plugin
|
||||
const manifestContent = import.meta.env.VITE_MANIFEST_CONTENT as
|
||||
| string
|
||||
| undefined;
|
||||
if (manifestContent) {
|
||||
try {
|
||||
return JSON.parse(manifestContent) as Manifest;
|
||||
} catch {
|
||||
// Fall through to auto-detection if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder replaced by inject-manifest plugin during SSR build
|
||||
const MANIFEST = "__VITE_MANIFEST_CONTENT__" as unknown as Record<
|
||||
string,
|
||||
{ default: Manifest }
|
||||
>;
|
||||
|
||||
let manifestData = {};
|
||||
for (const [, manifestFile] of Object.entries(MANIFEST)) {
|
||||
manifestData = { ...manifestData, ...manifestFile.default };
|
||||
}
|
||||
// Return merged values
|
||||
return manifestData;
|
||||
};
|
||||
const ensureTrailingSlash = (path: string) => {
|
||||
return path.endsWith("/") ? path : path + "/";
|
||||
};
|
||||
export const getHrefFromManifest = ({
|
||||
href,
|
||||
manifest,
|
||||
prod,
|
||||
baseUrl = "/",
|
||||
}: GetHrefOptions) => {
|
||||
if (!href) return undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain, @typescript-eslint/no-unnecessary-condition
|
||||
if (prod ?? (import.meta.env && import.meta.env.PROD)) {
|
||||
manifest ??= loadManifest();
|
||||
|
||||
if (manifest) {
|
||||
const assetInManifest = manifest[href.replace(/^\//, "")];
|
||||
return href.startsWith("/")
|
||||
? `${ensureTrailingSlash(baseUrl)}${assetInManifest.file}`
|
||||
: assetInManifest.file;
|
||||
}
|
||||
return undefined;
|
||||
} else {
|
||||
return href;
|
||||
}
|
||||
};
|
||||
export function buildBootstrapScript() {
|
||||
let script = "";
|
||||
let styles = "";
|
||||
let manifest: Manifest = import.meta.env.PROD
|
||||
? loadManifest() ?? {}
|
||||
: {
|
||||
"0": {
|
||||
file: "@vite/client",
|
||||
isEntry: true,
|
||||
css: [],
|
||||
imports: [],
|
||||
dynamicImports: [],
|
||||
assets: [],
|
||||
},
|
||||
"1": {
|
||||
file: "src/client.ts",
|
||||
isEntry: true,
|
||||
css: [],
|
||||
},
|
||||
};
|
||||
Object.values(manifest).forEach((chunk) => {
|
||||
if (chunk.isEntry) {
|
||||
script += `<script type="module" src="/${chunk.file}"></script>`;
|
||||
(chunk.css || []).forEach((cssFile) => {
|
||||
styles += `<link rel="stylesheet" crossorigin href="/${cssFile}">`;
|
||||
});
|
||||
} else {
|
||||
script += `<link rel="modulepreload" href="/${chunk.file}">`;
|
||||
(chunk.css || []).forEach((cssFile) => {
|
||||
styles += `<link rel="preload" as="style" href="/${cssFile}">`;
|
||||
});
|
||||
}
|
||||
});
|
||||
return styles + script;
|
||||
}
|
||||
@@ -4,4 +4,47 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
export function debounce<Func extends (...args: any[]) => any>(func: Func, wait: number): Func {
|
||||
let timeout: ReturnType<typeof setTimeout> | null;
|
||||
return function(this: any, ...args: any[]) {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
func.apply(this, args);
|
||||
}, wait);
|
||||
} as Func;
|
||||
}
|
||||
type AspectInfo = {
|
||||
width: number;
|
||||
height: number;
|
||||
ratio: string; // ví dụ: "16:9"
|
||||
float: number; // ví dụ: 1.777...
|
||||
};
|
||||
|
||||
function gcd(a: number, b: number): number {
|
||||
return b === 0 ? a : gcd(b, a % b);
|
||||
}
|
||||
|
||||
export function getImageAspectRatio(url: string): Promise<AspectInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
const w = img.naturalWidth;
|
||||
const h = img.naturalHeight;
|
||||
|
||||
const g = gcd(w, h);
|
||||
|
||||
resolve({
|
||||
width: w,
|
||||
height: h,
|
||||
ratio: `${w / g}:${h / g}`,
|
||||
float: w / h
|
||||
});
|
||||
};
|
||||
|
||||
img.onerror = () => reject(new Error("Cannot load image"));
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
43
src/main.ts
43
src/main.ts
@@ -5,13 +5,42 @@ import { RouterView } from 'vue-router';
|
||||
import { withErrorBoundary } from './lib/hoc/withErrorBoundary';
|
||||
import { vueSWR } from './lib/swr/use-swrv';
|
||||
import router from './routes';
|
||||
// import { appComponents } from './components'
|
||||
import PrimeVue from 'primevue/config';
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import { createPinia } from "pinia";
|
||||
import { useAuthStore } from './stores/auth';
|
||||
|
||||
const pinia = createPinia();
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(withErrorBoundary(RouterView));
|
||||
const head = import.meta.env.SSR ? SSRHead() : CSRHead();
|
||||
|
||||
const app = createSSRApp(withErrorBoundary(RouterView))
|
||||
const head = import.meta.env.SSR ? SSRHead() : CSRHead()
|
||||
app.use(head)
|
||||
app.use(vueSWR({revalidateOnFocus: false}))
|
||||
app.use(router)
|
||||
return { app, router, head }
|
||||
app.use(head);
|
||||
app.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
darkModeSelector: '.my-app-dark',
|
||||
}
|
||||
}
|
||||
});
|
||||
app.directive('no-hydrate', {
|
||||
created(el) {
|
||||
el.__v_skip = true;
|
||||
}
|
||||
});
|
||||
app.use(vueSWR({revalidateOnFocus: false}));
|
||||
app.use(router);
|
||||
app.use(pinia);
|
||||
|
||||
// Initialize auth store on client side
|
||||
if (!import.meta.env.SSR) {
|
||||
router.isReady().then(() => {
|
||||
const auth = useAuthStore();
|
||||
auth.init();
|
||||
});
|
||||
}
|
||||
|
||||
return { app, router, head };
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { jsxRenderer } from 'hono/jsx-renderer'
|
||||
import { Link, ViteClient } from 'vite-ssr-components/hono'
|
||||
|
||||
export const renderer = jsxRenderer(({ children }) => {
|
||||
return (
|
||||
<html>
|
||||
<head>
|
||||
<ViteClient />
|
||||
<Link href="/src/style.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
})
|
||||
3
src/routes/add/Add.vue
Normal file
3
src/routes/add/Add.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div>Add video</div>
|
||||
</template>
|
||||
56
src/routes/auth/forgot.vue
Normal file
56
src/routes/auth/forgot.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
|
||||
class="flex flex-col gap-4 w-full">
|
||||
<div class="text-sm text-gray-600 mb-2">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
|
||||
<InputText name="email" type="email" placeholder="you@example.com" fluid />
|
||||
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.email.error?.message }}</Message>
|
||||
</div>
|
||||
|
||||
<Button type="submit" label="Send Reset Link" fluid />
|
||||
|
||||
<div class="text-center mt-2">
|
||||
<router-link to="/login" replace
|
||||
class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
||||
</svg>
|
||||
Back to Sign in
|
||||
</router-link>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue';
|
||||
import { Form, type FormSubmitEvent } from '@primevue/forms';
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
||||
const initialValues = reactive({
|
||||
email: ''
|
||||
});
|
||||
|
||||
const resolver = zodResolver(
|
||||
z.object({
|
||||
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' })
|
||||
})
|
||||
);
|
||||
|
||||
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
|
||||
if (valid) {
|
||||
console.log('Form submitted:', values);
|
||||
// toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
|
||||
// Handle actual forgot password logic here
|
||||
}
|
||||
};
|
||||
</script>
|
||||
35
src/routes/auth/layout.vue
Normal file
35
src/routes/auth/layout.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="w-full max-w-md bg-white p-8 rounded-xl border border-primary m-auto">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 mb-4">
|
||||
<img class="w-12 h-12" src="/apple-touch-icon.png" alt="Logo" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900">
|
||||
{{ content[route.name as keyof typeof content]?.title || '' }}
|
||||
</h2>
|
||||
<p class="text-gray-500 text-sm mt-1">
|
||||
{{ content[route.name as keyof typeof content]?.subtitle || '' }}
|
||||
</p>
|
||||
</div>
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
const content = {
|
||||
login: {
|
||||
title: 'Welcome back',
|
||||
subtitle: 'Please enter your details to sign in.'
|
||||
},
|
||||
signup: {
|
||||
title: 'Create your account',
|
||||
subtitle: 'Please fill in the information to create your account.'
|
||||
},
|
||||
forgot: {
|
||||
title: 'Forgot your password?',
|
||||
subtitle: "Enter your email address and we'll send you a link to reset your password."
|
||||
}
|
||||
}
|
||||
</script>
|
||||
114
src/routes/auth/login.vue
Normal file
114
src/routes/auth/login.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
|
||||
class="flex flex-col gap-4 w-full">
|
||||
|
||||
<!-- Global error message -->
|
||||
<Message v-if="auth.error" severity="error" size="small" variant="simple">
|
||||
{{ auth.error }}
|
||||
</Message>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="email" class="text-sm font-medium text-gray-700">Email or Username</label>
|
||||
<InputText name="email" type="text" placeholder="admin or user@example.com" fluid
|
||||
:disabled="auth.loading" />
|
||||
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.email.error?.message }}</Message>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
|
||||
<Password name="password" placeholder="••••••••" :feedback="false" toggleMask fluid
|
||||
:inputStyle="{ width: '100%' }" :disabled="auth.loading" />
|
||||
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.password.error?.message }}</Message>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox inputId="remember-me" name="rememberMe" binary :disabled="auth.loading" />
|
||||
<label for="remember-me" class="text-sm text-gray-900">Remember me</label>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<router-link to="/forgot"
|
||||
class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Forgot
|
||||
password?</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" :label="auth.loading ? 'Signing in...' : 'Sign in'" fluid :loading="auth.loading" />
|
||||
|
||||
<div class="relative my-4">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-white text-gray-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="outlined" severity="secondary"
|
||||
class="w-full flex items-center justify-center gap-2" @click="loginWithGoogle" :disabled="auth.loading">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z" />
|
||||
</svg>
|
||||
Google
|
||||
</Button>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-gray-600">
|
||||
Don't have an account?
|
||||
<router-link to="/signup" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign up
|
||||
for free</router-link>
|
||||
</p>
|
||||
|
||||
<!-- Hint for demo credentials -->
|
||||
<div class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p class="text-xs text-blue-800 font-medium mb-1">Demo Credentials:</p>
|
||||
<p class="text-xs text-blue-600">Username: <code class="bg-blue-100 px-1 rounded">admin</code> |
|
||||
Password: <code class="bg-blue-100 px-1 rounded">admin123</code></p>
|
||||
<p class="text-xs text-blue-600">Email: <code class="bg-blue-100 px-1 rounded">user@example.com</code> |
|
||||
Password: <code class="bg-blue-100 px-1 rounded">password</code></p>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue';
|
||||
import { Form, type FormSubmitEvent } from '@primevue/forms';
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const initialValues = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: false
|
||||
});
|
||||
|
||||
const resolver = zodResolver(
|
||||
z.object({
|
||||
email: z.string().min(1, { message: 'Email or username is required.' }),
|
||||
password: z.string().min(1, { message: 'Password is required.' })
|
||||
})
|
||||
);
|
||||
|
||||
const onFormSubmit = async ({ valid, values }: FormSubmitEvent) => {
|
||||
if (valid) {
|
||||
try {
|
||||
await auth.login(values.email, values.password);
|
||||
} catch (error) {
|
||||
// Error is already set in the store
|
||||
console.error('Login failed:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loginWithGoogle = () => {
|
||||
console.log('Login with Google');
|
||||
// Handle Google login logic here
|
||||
};
|
||||
</script>
|
||||
67
src/routes/auth/signup.vue
Normal file
67
src/routes/auth/signup.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
|
||||
class="flex flex-col gap-4 w-full">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="name" class="text-sm font-medium text-gray-700">Full Name</label>
|
||||
<InputText name="name" placeholder="John Doe" fluid />
|
||||
<Message v-if="$form.name?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.name.error?.message }}</Message>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
|
||||
<InputText name="email" type="email" placeholder="you@example.com" fluid />
|
||||
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.email.error?.message }}</Message>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
|
||||
<Password name="password" placeholder="Create a password" :feedback="true" toggleMask fluid
|
||||
:inputStyle="{ width: '100%' }" />
|
||||
<small class="text-gray-500">Must be at least 8 characters.</small>
|
||||
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.password.error?.message }}</Message>
|
||||
</div>
|
||||
|
||||
<Button type="submit" label="Create Account" fluid />
|
||||
|
||||
<p class="mt-4 text-center text-sm text-gray-600">
|
||||
Already have an account?
|
||||
<router-link to="/login" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign
|
||||
in</router-link>
|
||||
</p>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue';
|
||||
import { Form, type FormSubmitEvent } from '@primevue/forms';
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
||||
const initialValues = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const resolver = zodResolver(
|
||||
z.object({
|
||||
name: z.string().min(1, { message: 'Name is required.' }),
|
||||
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' }),
|
||||
password: z.string().min(8, { message: 'Password must be at least 8 characters.' })
|
||||
})
|
||||
);
|
||||
|
||||
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
|
||||
if (valid) {
|
||||
console.log('Form submitted:', values);
|
||||
// toast.add({ severity: 'success', summary: 'Success', detail: 'Account created successfully', life: 3000 });
|
||||
// Handle actual signup logic here
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -1,7 +1,231 @@
|
||||
<template>
|
||||
<div>Home</div>
|
||||
<nav class="fixed w-full z-50 glass-nav transition-all duration-300" id="navbar">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center gap-2 cursor-pointer" onclick="window.scrollTo(0,0)">
|
||||
<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />
|
||||
<span class="font-bold text-xl tracking-tight text-slate-900">EcoStream</span>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Menu -->
|
||||
<div class="hidden md:flex items-center space-x-8">
|
||||
<a href="#features" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">Features</a>
|
||||
<a href="#analytics" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">Analytics</a>
|
||||
<a href="#pricing" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">Pricing</a>
|
||||
</div>
|
||||
|
||||
<!-- Auth Buttons -->
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
<RouterLink to="/login" class="text-sm font-semibold text-slate-600 hover:text-slate-900 cursor-pointer">Log in</RouterLink>
|
||||
<RouterLink to="/signup" class="bg-slate-900 hover:bg-black text-white px-5 py-2.5 rounded-lg text-sm font-semibold cursor-pointer">
|
||||
Start for free
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<section class="relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden">
|
||||
<!-- Background Elements -->
|
||||
<div class="absolute inset-0 opacity-[0.4] -z-10"></div>
|
||||
<div class="absolute top-0 right-0 -translate-y-1/2 translate-x-1/2 w-[800px] h-[800px] bg-brand-100/50 rounded-full blur-3xl -z-10 mix-blend-multiply"></div>
|
||||
<div class="absolute bottom-0 left-0 translate-y-1/2 -translate-x-1/2 w-[600px] h-[600px] bg-teal-100/50 rounded-full blur-3xl -z-10 mix-blend-multiply"></div>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h1 class="text-5xl md:text-7xl font-extrabold tracking-tight text-slate-900 mb-6 leading-[1.1]">
|
||||
Video infrastructure for <br>
|
||||
<span class="text-gradient">modern internet.</span>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl text-slate-500 max-w-2xl mx-auto mb-10 leading-relaxed">
|
||||
Seamlessly host, encode, and stream video with our developer-first API.
|
||||
Optimized for speed, built for scale.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row justify-center gap-4">
|
||||
<RouterLink to="/get-started" class="flex btn btn-secondary !rounded-xl !p-4 press-animated">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="46 -286 524 580"><path d="M56 284v-560L560 4 56 284z" fill="#fff"/></svg>
|
||||
Get Started
|
||||
</RouterLink>
|
||||
<RouterLink to="/docs" class="flex btn btn-outline-primary !rounded-xl">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -261 468 503"><path d="M256-139V72c0 18-14 32-32 32s-32-14-32-32v-211l-41 42c-13 12-33 12-46 0-12-13-12-33 0-46l96-96c13-12 33-12 46 0l96 96c12 13 12 33 0 46-13 12-33 12-46 0l-41-42zm-32 291c44 0 80-36 80-80h80c35 0 64 29 64 64v32c0 35-29 64-64 64H64c-35 0-64-29-64-64v-32c0-35 29-64 64-64h80c0 44 36 80 80 80zm144 24c13 0 24-11 24-24s-11-24-24-24-24 11-24 24 11 24 24 24z" fill="#14a74b"/></svg>
|
||||
Upload video
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="features" class="py-24 bg-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-16 md:text-center max-w-3xl mx-auto">
|
||||
<h2 class="text-3xl font-bold text-slate-900 mb-4">Everything you need to ship video</h2>
|
||||
<p class="text-lg text-slate-500">Focus on building your product. We'll handle the complex video infrastructure.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Large Feature -->
|
||||
<div class="md:col-span-2 bg-slate-50 rounded-2xl p-8 border border-slate-100 hover:border-primary/60 transition-all group overflow-hidden relative">
|
||||
<div class="relative z-10">
|
||||
<div class="w-12 h-12 bg-white rounded-xl flex items-center justify-center mb-6 border border-slate-100">
|
||||
<!-- <i class="fas fa-globe text-xl"></i> -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="532" viewBox="-8 -258 529 532"><path d="M342 32c-2 69-16 129-35 172-10 23-22 40-32 49-10 10-16 11-19 11h-1c-3 0-9-1-19-11-10-9-22-26-32-49-19-43-33-103-35-172h173zm169 0c-9 103-80 188-174 219 30-51 50-129 53-219h121zm-390 0c3 89 23 167 53 218C80 219 11 134 2 32h119zm53-266c-30 51-50 129-53 218H2c9-102 78-186 172-218zm82-14c3 0 9 1 19 11 10 9 22 26 32 50 19 42 33 102 35 171H169c3-69 16-129 35-171 10-24 22-41 32-50s16-11 19-11h1zm81 13c94 31 165 116 174 219H390c-3-90-23-168-53-219z" fill="#059669"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-slate-900 mb-2">Global Edge Network</h3>
|
||||
<p class="text-slate-500 max-w-md">Content delivered from 200+ PoPs worldwide. Automatic region selection ensures the lowest latency for every viewer.</p>
|
||||
</div>
|
||||
<!-- Decor -->
|
||||
<div class="absolute right-0 bottom-0 opacity-10 translate-x-1/4 translate-y-1/4">
|
||||
<!-- <i class="fas fa-globe-americas text-[200px] text-brand-900"></i> -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="-10 -258 532 532"><path d="M464 8c0-19-3-38-8-56l-27-5c-8-2-15 2-19 9-6 11-19 17-31 13l-14-5c-8-2-17 0-22 5-4 4-4 10 0 14l33 33c5 5 8 12 8 19 0 12-8 23-20 26l-6 1c-3 1-6 5-6 9v12c0 13-4 27-13 38l-25 34c-6 8-16 13-26 13-18 0-32-14-32-32V88c0-9-7-16-16-16h-32c-26 0-48-22-48-48V-4c0-13 6-24 16-32l39-30c6-4 13-6 20-6 3 0 7 1 10 2l32 10c7 3 15 3 22 1l36-9c10-2 17-11 17-22 0-8-5-16-13-20l-29-15c-3-2-8-1-11 2l-4 4c-4 4-11 7-17 7-4 0-8-1-11-3l-15-7c-7-4-15-2-20 4l-13 17c-6 7-16 8-22 1-3-2-5-6-5-10v-41c0-6-1-11-4-16l-10-18C102-154 48-79 48 8c0 115 93 208 208 208S464 123 464 8zM0 8c0-141 115-256 256-256S512-133 512 8 397 264 256 264 0 149 0 8z" fill="#1e3050"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tall Feature -->
|
||||
<div class="md:row-span-2 bg-slate-900 rounded-2xl p-8 text-white relative overflow-hidden group">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-slate-800/50 to-transparent"></div>
|
||||
<div class="relative z-10">
|
||||
<div class="w-12 h-12 bg-white/10 rounded-xl flex items-center justify-center mb-6 backdrop-blur-sm border border-white/10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -146 468 384"><path d="M392-136c-31 0-56 25-56 56v280c0 16 13 28 28 28h28c31 0 56-25 56-56V-80c0-31-25-56-56-56zM168 4c0-31 25-56 56-56h28c16 0 28 13 28 28v224c0 16-12 28-28 28h-56c-15 0-28-12-28-28V4zM0 88c0-31 25-56 56-56h28c16 0 28 13 28 28v140c0 16-12 28-28 28H56c-31 0-56-25-56-56V88z" fill="#fff"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2">Live Streaming API</h3>
|
||||
<p class="text-slate-400 text-sm leading-relaxed mb-8">Scale to millions of concurrent viewers with ultra-low latency. RTMP ingest and HLS playback supported natively.</p>
|
||||
|
||||
<!-- Visual -->
|
||||
<div class="bg-slate-800/50 rounded-lg p-4 border border-white/5 font-mono text-xs text-brand-300">
|
||||
<div class="flex justify-between items-center mb-3 border-b border-white/5 pb-2">
|
||||
<span class="text-slate-500">Live Status</span>
|
||||
<span class="flex items-center gap-1.5 text-red-500 text-[10px] uppercase font-bold tracking-wider animate-pulse"><span class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse"></span> On Air</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between"><span class="text-slate-400">Bitrate:</span> <span class="text-white">6000 kbps</span></div>
|
||||
<div class="flex justify-between"><span class="text-slate-400">FPS:</span> <span class="text-white">60</span></div>
|
||||
<div class="flex justify-between"><span class="text-slate-400">Latency:</span> <span class="text-brand-400">~2s</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Standard Feature -->
|
||||
<div class="bg-slate-50 rounded-2xl p-8 border border-slate-100 hover:border-brand-200 transition-all group hover:shadow-lg hover:shadow-brand-500/5">
|
||||
<div class="w-12 h-12 bg-white rounded-xl shadow-sm flex items-center justify-center mb-6 text-purple-600 border border-slate-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="0 0 570 570"><path d="M50 428c-5 5-5 14 0 19s14 5 19 0l237-237c5-5 5-14 0-19s-14-5-19 0L50 428zm16-224c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zM174 60c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zm215 29c-5 5-5 14 0 19s14 5 19 0l39-39c5-5 5-14 0-19s-14-5-19 0l-39 39zm21 357c-5 5-5 14 0 19s14 5 19 0l18-18c5-5 5-14 0-19s-14-5-19 0l-18 18z" fill="#a6acb9"/><path d="M170 26c14-15 36-15 50 0l18 18c15 14 15 36 0 50l-18 18c-14 15-36 15-50 0l-18-18c-15-14-15-36 0-50l18-18zm35 41c5-5 5-14 0-19-6-5-14-5-20 0l-11 12c-5 5-5 13 0 19 5 5 14 5 19 0l12-12zm204 342c21-21 55-21 76 0l18 18c21 21 21 55 0 76l-18 18c-21 21-55 21-76 0l-18-18c-21-21-21-55 0-76l18-18zm38 38c5-5 5-14 0-19s-14-5-19 0l-18 18c-5 5-5 14 0 19s14 5 19 0l18-18zM113 170c-15-15-37-15-51 0l-18 18c-14 14-14 36 0 50l18 18c14 15 37 15 51 0l18-18c14-14 14-36 0-50l-18-18zm-16 41-12 12c-5 5-14 5-19 0-5-6-5-14 0-20l11-11c6-5 14-5 20 0 5 5 5 14 0 19zM485 31c-21-21-55-21-76 0l-39 39c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l39-39c21-21 21-55 0-76l-54-54zm-38 38-39 39c-5 5-14 5-19 0s-5-14 0-19l39-39c5-5 14-5 19 0s5 14 0 19zm-49 233c21-21 21-55 0-76l-54-54c-21-21-55-21-76 0L31 409c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l237-237zm-92-92L69 447c-5 5-14 5-19 0s-5-14 0-19l237-237c5-5 14-5 19 0s5 14 0 19z" fill="#1e3050"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-slate-900 mb-2">Instant Encoding</h3>
|
||||
<p class="text-slate-500 text-sm">Upload raw files and get optimized HLS/DASH streams in seconds.</p>
|
||||
</div>
|
||||
|
||||
<!-- Standard Feature -->
|
||||
<div class="bg-slate-50 rounded-2xl p-8 border border-slate-100 hover:border-brand-200 transition-all group hover:shadow-lg hover:shadow-brand-500/5">
|
||||
<div class="w-12 h-12 bg-white rounded-xl shadow-sm flex items-center justify-center mb-6 text-orange-600 border border-slate-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -226 532 468"><path d="M32-216c18 0 32 14 32 32v336c0 9 7 16 16 16h400c18 0 32 14 32 32s-14 32-32 32H80c-44 0-80-36-80-80v-336c0-18 14-32 32-32zM144-24c18 0 32 14 32 32v64c0 18-14 32-32 32s-32-14-32-32V8c0-18 14-32 32-32zm144-64V72c0 18-14 32-32 32s-32-14-32-32V-88c0-18 14-32 32-32s32 14 32 32zm80 32c18 0 32 14 32 32v96c0 18-14 32-32 32s-32-14-32-32v-96c0-18 14-32 32-32zm144-96V72c0 18-14 32-32 32s-32-14-32-32v-224c0-18 14-32 32-32s32 14 32 32z" fill="#1e3050"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-slate-900 mb-2">Deep Analytics</h3>
|
||||
<p class="text-slate-500 text-sm">Session-level insights, quality of experience (QoE) metrics, and more.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Pricing -->
|
||||
<section id="pricing" class="py-24 bg-white border-t border-slate-100">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl font-bold text-slate-900 mb-4">Simple, transparent pricing</h2>
|
||||
<p class="text-slate-500">No hidden fees. Pay as you grow.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
||||
<!-- Hobby -->
|
||||
<div class="p-8 rounded-2xl border border-slate-200 hover:border-slate-300 transition-colors">
|
||||
<h3 class="font-semibold text-slate-900 mb-2">Hobby</h3>
|
||||
<div class="flex items-baseline gap-1 mb-6">
|
||||
<span class="text-4xl font-bold text-slate-900">$0</span>
|
||||
<span class="text-slate-500">/mo</span>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8 text-sm text-slate-600">
|
||||
<li class="flex items-center gap-3"><i class="fas fa-check text-brand-500"></i> 100 GB Bandwidth</li>
|
||||
<li class="flex items-center gap-3"><i class="fas fa-check text-brand-500"></i> 1 Hour of Storage</li>
|
||||
<li class="flex items-center gap-3"><i class="fas fa-check text-brand-500"></i> Standard Support</li>
|
||||
</ul>
|
||||
<button class="w-full py-2.5 rounded-lg border border-slate-200 font-semibold text-slate-700 hover:bg-slate-50 transition-colors">Start Free</button>
|
||||
</div>
|
||||
|
||||
<!-- Pro -->
|
||||
<div class="p-8 rounded-2xl bg-slate-900 text-white shadow-2xl relative overflow-hidden transform">
|
||||
<div class="absolute top-0 right-0 bg-primary/50 text-white text-xs font-bold px-3 py-1 rounded-bl-lg">POPULAR</div>
|
||||
<h3 class="font-semibold mb-2 text-brand-400">Pro</h3>
|
||||
<div class="flex items-baseline gap-1 mb-6">
|
||||
<span class="text-4xl font-bold">$0</span>
|
||||
<span class="text-lg font-bold line-through">$29</span>
|
||||
<span class="text-slate-400">/mo</span>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8 text-sm text-slate-300">
|
||||
<li class="flex items-center gap-3"><i class="fas fa-check text-primary/60"></i> 1 TB Bandwidth</li>
|
||||
<li class="flex items-center gap-3"><i class="fas fa-check text-primary/60"></i> 100 Hours Storage</li>
|
||||
<li class="flex items-center gap-3"><i class="fas fa-check text-primary/60"></i> Remove Branding</li>
|
||||
<li class="flex items-center gap-3"><i class="fas fa-check text-primary/60"></i> 4K Encoding</li>
|
||||
</ul>
|
||||
<button class="w-full py-2.5 rounded-lg bg-primary/60 hover:bg-primary/70 font-semibold transition-colors shadow-lg shadow-primary/30">Get Started</button>
|
||||
</div>
|
||||
|
||||
<!-- Scale -->
|
||||
<div class="p-8 rounded-2xl border border-slate-200 hover:border-slate-300 transition-colors">
|
||||
<h3 class="font-semibold text-slate-900 mb-2">Scale</h3>
|
||||
<div class="flex items-baseline gap-1 mb-6">
|
||||
<span class="text-4xl font-bold text-slate-900">$99</span>
|
||||
<span class="text-slate-500">/mo</span>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8 text-sm text-slate-600">
|
||||
<li class="flex items-center gap-3"><i class="fas fa-check text-brand-500"></i> 5 TB Bandwidth</li>
|
||||
<li class="flex items-center gap-3"><i class="fas fa-check text-brand-500"></i> 500 Hours Storage</li>
|
||||
<li class="flex items-center gap-3"><i class="fas fa-check text-brand-500"></i> Priority Support</li>
|
||||
</ul>
|
||||
<button class="w-full py-2.5 rounded-lg border border-slate-200 font-semibold text-slate-700 hover:bg-slate-50 transition-colors">Contact Sales</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-white border-t border-slate-100 pt-16 pb-8">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-8 mb-12">
|
||||
<div class="col-span-2">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="w-6 h-6 bg-brand-600 rounded flex items-center justify-center text-white">
|
||||
<img class="h-6 w-6" src="/apple-touch-icon.png" alt="Logo" />
|
||||
</div>
|
||||
<span class="font-bold text-lg text-slate-900">EcoStream</span>
|
||||
</div>
|
||||
<p class="text-slate-500 text-sm max-w-xs">Building the video layer of the internet. Designed for developers.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Product</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-500">
|
||||
<li><a href="#" class="hover:text-brand-600">Features</a></li>
|
||||
<li><a href="#" class="hover:text-brand-600">Pricing</a></li>
|
||||
<li><a href="#" class="hover:text-brand-600">Showcase</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Company</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-500">
|
||||
<li><a href="#" class="hover:text-brand-600">About</a></li>
|
||||
<li><a href="#" class="hover:text-brand-600">Blog</a></li>
|
||||
<li><a href="#" class="hover:text-brand-600">Careers</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Legal</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-500">
|
||||
<li><a href="#" class="hover:text-brand-600">Privacy</a></li>
|
||||
<li><a href="#" class="hover:text-brand-600">Terms</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-8 border-t border-slate-100 text-center text-sm text-slate-400">
|
||||
© 2026 EcoStream Inc. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
<script lang="ts" setup>
|
||||
</script>
|
||||
|
||||
@@ -5,15 +5,78 @@ import {
|
||||
createWebHistory,
|
||||
type RouteRecordRaw,
|
||||
} from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
type RouteData = RouteRecordRaw & {
|
||||
meta?: ResolvableValue<ReactiveHead>;
|
||||
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean };
|
||||
children?: RouteData[];
|
||||
};
|
||||
const routes: RouteData[] = [
|
||||
{
|
||||
path: "/",
|
||||
component: () => import("./home/Home.vue")
|
||||
component: () => import("@/components/RootLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: () => import("./home/Home.vue"),
|
||||
beforeEnter: (to, from, next) => {
|
||||
const auth = useAuthStore();
|
||||
if (auth.user) {
|
||||
next({ name: "overview" });
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
component: () => import("./auth/layout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "login",
|
||||
name: "login",
|
||||
component: () => import("./auth/login.vue"),
|
||||
},
|
||||
{
|
||||
path: "signup",
|
||||
name: "signup",
|
||||
component: () => import("./auth/signup.vue"),
|
||||
},
|
||||
{
|
||||
path: "forgot",
|
||||
name: "forgot",
|
||||
component: () => import("./auth/forgot.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
component: () => import("@/components/DashboardLayout.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "overview",
|
||||
component: () => import("./add/Add.vue"),
|
||||
},
|
||||
{
|
||||
path: "video",
|
||||
name: "video",
|
||||
component: () => import("./add/Add.vue"),
|
||||
},
|
||||
{
|
||||
path: "add",
|
||||
name: "add",
|
||||
component: () => import("./add/Add.vue"),
|
||||
},
|
||||
{
|
||||
path: "notification",
|
||||
name: "notification",
|
||||
component: () => import("./add/Add.vue"),
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
];
|
||||
const router = createRouter({
|
||||
@@ -22,4 +85,18 @@ const router = createRouter({
|
||||
: createWebHistory(), // client
|
||||
routes,
|
||||
});
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const auth = useAuthStore();
|
||||
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||
if (!auth.user) {
|
||||
next({ name: "login" });
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
88
src/stores/auth.ts
Normal file
88
src/stores/auth.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { client } from '@/api/rpcclient';
|
||||
import { ref } from 'vue';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null);
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const csrfToken = ref<string | null>(null);
|
||||
const initialized = ref(false);
|
||||
|
||||
// Check auth status on init (reads from cookie)
|
||||
async function init() {
|
||||
if (initialized.value) return;
|
||||
|
||||
try {
|
||||
const response = await client.checkAuth();
|
||||
if (response.authenticated && response.user) {
|
||||
user.value = response.user;
|
||||
// Get CSRF token if authenticated
|
||||
try {
|
||||
const csrfResponse = await client.getCSRFToken();
|
||||
csrfToken.value = csrfResponse.csrfToken;
|
||||
} catch (e) {
|
||||
// CSRF token might not be available yet
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not authenticated, that's fine
|
||||
} finally {
|
||||
initialized.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await client.login(username, password);
|
||||
user.value = response.user;
|
||||
csrfToken.value = response.csrfToken;
|
||||
router.push('/');
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Login failed';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function register(username: string, email: string, password: string) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await client.register({ username, email, password });
|
||||
user.value = response.user;
|
||||
csrfToken.value = response.csrfToken;
|
||||
router.push('/');
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Registration failed';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await client.logout();
|
||||
} catch (e) {
|
||||
// Ignore errors on logout
|
||||
}
|
||||
user.value = null;
|
||||
csrfToken.value = null;
|
||||
router.push('/');
|
||||
}
|
||||
|
||||
return { user, loading, error, csrfToken, initialized, init, login, register, logout };
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
h1 {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
56
src/worker/html.ts
Normal file
56
src/worker/html.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @module
|
||||
* html Helper for Hono.
|
||||
*/
|
||||
|
||||
import { escapeToBuffer, raw, resolveCallbackSync, stringBufferToString } from 'hono/utils/html'
|
||||
import type { HtmlEscaped, HtmlEscapedString, StringBufferWithCallbacks } from 'hono/utils/html'
|
||||
|
||||
|
||||
export const html = (
|
||||
strings: TemplateStringsArray,
|
||||
...values: unknown[]
|
||||
): HtmlEscapedString | Promise<HtmlEscapedString> => {
|
||||
const buffer: StringBufferWithCallbacks = [''] as StringBufferWithCallbacks
|
||||
|
||||
for (let i = 0, len = strings.length - 1; i < len; i++) {
|
||||
buffer[0] += strings[i]
|
||||
|
||||
const children = Array.isArray(values[i])
|
||||
? (values[i] as Array<unknown>).flat(Infinity)
|
||||
: [values[i]]
|
||||
for (let i = 0, len = children.length; i < len; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const child = children[i] as any
|
||||
if (typeof child === 'string') {
|
||||
escapeToBuffer(child, buffer)
|
||||
} else if (typeof child === 'number') {
|
||||
;(buffer[0] as string) += child
|
||||
} else if (typeof child === 'boolean' || child === null || child === undefined) {
|
||||
continue
|
||||
} else if (typeof child === 'object' && (child as HtmlEscaped).isEscaped) {
|
||||
if ((child as HtmlEscapedString).callbacks) {
|
||||
buffer.unshift('', child)
|
||||
} else {
|
||||
const tmp = child.toString()
|
||||
if (tmp instanceof Promise) {
|
||||
buffer.unshift('', tmp)
|
||||
} else {
|
||||
buffer[0] += tmp
|
||||
}
|
||||
}
|
||||
} else if (child instanceof Promise) {
|
||||
buffer.unshift('', child)
|
||||
} else {
|
||||
escapeToBuffer(child.toString(), buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
buffer[0] += strings.at(-1) as string
|
||||
|
||||
return buffer.length === 1
|
||||
? 'callbacks' in buffer
|
||||
? raw(resolveCallbackSync(raw(buffer[0], buffer.callbacks)))
|
||||
: raw(buffer[0])
|
||||
: stringBufferToString(buffer, buffer.callbacks)
|
||||
}
|
||||
65
src/worker/ssrLayout.ts
Normal file
65
src/worker/ssrLayout.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createContext, jsx, Suspense } from "hono/jsx";
|
||||
import { renderToReadableStream, StreamingContext } from "hono/jsx/streaming";
|
||||
import { HtmlEscapedCallback, HtmlEscapedString, raw } from "hono/utils/html";
|
||||
// import { jsxs } from "hono/jsx-renderer";
|
||||
import { Context } from "hono";
|
||||
import type {
|
||||
FC,
|
||||
Context as JSXContext,
|
||||
JSXNode
|
||||
} from "hono/jsx";
|
||||
import { jsxTemplate } from "hono/jsx/jsx-runtime";
|
||||
export const RequestContext: JSXContext<Context<any, any, {}> | null> =
|
||||
createContext<Context | null>(null);
|
||||
export function renderSSRLayout(c: Context, appStream: ReadableStream) {
|
||||
const body = jsxTemplate`${raw("<!DOCTYPE html>")}${_c(
|
||||
RequestContext.Provider,
|
||||
{ value: c },
|
||||
// currentLayout as any
|
||||
_c(
|
||||
"html",
|
||||
{ lang: "en" },
|
||||
_c(
|
||||
"head",
|
||||
null,
|
||||
raw('<meta charset="UTF-8"/>'),
|
||||
raw(
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1.0"/>'
|
||||
),
|
||||
raw('<link rel="icon" href="/favicon.ico" />'),
|
||||
raw(`<base href="${new URL(c.req.url).origin}/"/>`)
|
||||
),
|
||||
_c(
|
||||
"body",
|
||||
{
|
||||
class:
|
||||
"font-sans bg-[#f9fafd] text-gray-800 antialiased flex flex-col",
|
||||
},
|
||||
_c(
|
||||
StreamingContext,
|
||||
{ value: { scriptNonce: "random-nonce-value" } },
|
||||
_c(
|
||||
Suspense,
|
||||
{ fallback: _c("div", { class: "loading" }, raw("Loading...")) },
|
||||
raw(appStream.getReader())
|
||||
)
|
||||
),
|
||||
_c("script", {
|
||||
dangerouslySetInnerHTML: {
|
||||
__html: `window.__SSR_STATE__ = ${JSON.stringify(
|
||||
JSON.stringify(c.get("ssrContext") || {})
|
||||
)};`,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
)}`;
|
||||
return renderToReadableStream(body);
|
||||
}
|
||||
function _c(
|
||||
tag: string | FC<any>,
|
||||
props: any,
|
||||
...children: (JSXNode | HtmlEscapedCallback | HtmlEscapedString | null)[]
|
||||
): JSXNode {
|
||||
return jsx(tag, props, ...(children as any));
|
||||
}
|
||||
Reference in New Issue
Block a user