init commit
This commit is contained in:
95
frontend/linktree-frontend/src/app/auth/login/page.tsx
Normal file
95
frontend/linktree-frontend/src/app/auth/login/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
// src/app/auth/login/page.tsx
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
type FormData = { username: string; password: string }
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>()
|
||||
const [apiError, setApiError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && localStorage.getItem('token')) {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
async function onSubmit(data: FormData) {
|
||||
setApiError(null)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/auth/login/`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
)
|
||||
if (!res.ok) {
|
||||
const json = await res.json()
|
||||
setApiError(json.detail || 'Ошибка входа')
|
||||
return
|
||||
}
|
||||
const { access } = await res.json()
|
||||
localStorage.setItem('token', access)
|
||||
router.push('/dashboard')
|
||||
} catch {
|
||||
setApiError('Сетевая ошибка')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="d-flex align-items-center justify-content-center" style={{ height: '100vh' }}>
|
||||
<div className="card shadow-lg border-0" style={{ maxWidth: 800, width: '100%' }}>
|
||||
<div className="row g-0">
|
||||
<div className="col-lg-6 d-none d-lg-flex" style={{
|
||||
backgroundImage: "url('/assets/img/durvill_logo.jpg')",
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
}} />
|
||||
<div className="col-lg-6">
|
||||
<div className="p-5">
|
||||
<h4 className="text-center mb-4">Welcome back!</h4>
|
||||
{apiError && <p className="text-danger text-center">{apiError}</p>}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter Username..."
|
||||
className={`form-control ${errors.username ? 'is-invalid' : ''}`}
|
||||
{...register('username', { required: 'Введите имя пользователя' })}
|
||||
/>
|
||||
{errors.username && <div className="invalid-feedback">{errors.username.message}</div>}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
className={`form-control ${errors.password ? 'is-invalid' : ''}`}
|
||||
{...register('password', { required: 'Введите пароль' })}
|
||||
/>
|
||||
{errors.password && <div className="invalid-feedback">{errors.password.message}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="btn btn-primary w-100"
|
||||
style={{ background: '#01703E' }}
|
||||
>
|
||||
{isSubmitting ? 'Вхожу...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="text-center mt-3">
|
||||
<a href="#" className="small text-decoration-none">Forgot Password?</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// src/components/ProfileCard.tsx
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { format } from 'date-fns'
|
||||
import ru from 'date-fns/locale/ru'
|
||||
|
||||
interface ProfileCardProps {
|
||||
avatar: string // API теперь отдаёт что-то вроде "frontend/assets/img/avatars/3.png"
|
||||
full_name: string
|
||||
email: string
|
||||
bio?: string
|
||||
last_login: string
|
||||
date_joined: string
|
||||
}
|
||||
|
||||
export function ProfileCard({
|
||||
avatar,
|
||||
full_name,
|
||||
email,
|
||||
bio,
|
||||
last_login,
|
||||
date_joined,
|
||||
}: ProfileCardProps) {
|
||||
// Если API отдаёт относительный путь без /media/, добавляем префикс:
|
||||
const avatarSrc = avatar.startsWith('http')
|
||||
? avatar
|
||||
: `${process.env.NEXT_PUBLIC_API_URL}/media/${avatar}`
|
||||
|
||||
const fmt = (iso: string) => {
|
||||
try {
|
||||
return format(new Date(iso), 'dd.MM.yyyy HH:mm', { locale: ru })
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card shadow rounded mx-auto my-4"
|
||||
style={{ maxWidth: 600 }}
|
||||
>
|
||||
<div className="card-body text-center">
|
||||
{/* Avatar */}
|
||||
<div className="mb-3">
|
||||
<Image
|
||||
className="rounded-circle border border-white"
|
||||
src={avatarSrc}
|
||||
alt="Avatar"
|
||||
width={150}
|
||||
height={150}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Full Name */}
|
||||
<h3 className="mb-1">{full_name || '—'}</h3>
|
||||
|
||||
{/* Email */}
|
||||
<p className="text-muted mb-3">{email}</p>
|
||||
|
||||
{/* Bio */}
|
||||
<p className="mb-4">
|
||||
{bio && bio.trim() ? bio : 'Описание профиля отсутствует.'}
|
||||
</p>
|
||||
|
||||
{/* Даты регистрации и последнего входа */}
|
||||
<div className="d-flex justify-content-around">
|
||||
<div className="text-start">
|
||||
<p className="mb-1 small text-uppercase">Зарегистрирован</p>
|
||||
<p className="mb-0">{fmt(date_joined)}</p>
|
||||
</div>
|
||||
<div className="text-end">
|
||||
<p className="mb-1 small text-uppercase">Последний вход</p>
|
||||
<p className="mb-0">{fmt(last_login)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
frontend/linktree-frontend/src/app/components/footer.tsx
Normal file
31
frontend/linktree-frontend/src/app/components/footer.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="bg-light footer py-5 border-top">
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-lg-6 text-center text-lg-start mb-3 mb-lg-0">
|
||||
<ul className="list-inline mb-2">
|
||||
<li className="list-inline-item"><Link href="#">About</Link></li>
|
||||
<li className="list-inline-item"><span>⋅</span></li>
|
||||
<li className="list-inline-item"><Link href="#">Contact</Link></li>
|
||||
<li className="list-inline-item"><span>⋅</span></li>
|
||||
<li className="list-inline-item"><Link href="#">Terms of Use</Link></li>
|
||||
<li className="list-inline-item"><span>⋅</span></li>
|
||||
<li className="list-inline-item"><Link href="#">Privacy Policy</Link></li>
|
||||
</ul>
|
||||
<p className="text-muted small mb-0">© CatLink 2025. Все права защищены.</p>
|
||||
</div>
|
||||
<div className="col-lg-6 text-center text-lg-end">
|
||||
<ul className="list-inline mb-0">
|
||||
<li className="list-inline-item"><Link href="#"><i className="fa fa-facebook fa-2x fa-fw" /></Link></li>
|
||||
<li className="list-inline-item"><Link href="#"><i className="fa fa-twitter fa-2x fa-fw" /></Link></li>
|
||||
<li className="list-inline-item"><Link href="#"><i className="fa fa-instagram fa-2x fa-fw" /></Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
72
frontend/linktree-frontend/src/app/components/header.tsx
Normal file
72
frontend/linktree-frontend/src/app/components/header.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface UserProfile {
|
||||
username: string
|
||||
first_name?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const [user, setUser] = useState<UserProfile | null>(null)
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
return
|
||||
}
|
||||
fetch('/api/auth/user/', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('unauth')
|
||||
return res.json()
|
||||
})
|
||||
.then(setUser)
|
||||
.catch(() => {
|
||||
localStorage.removeItem('token')
|
||||
router.push('/auth/login')
|
||||
})
|
||||
}, [router])
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token')
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="navbar navbar-expand bg-light fixed-top">
|
||||
<div className="container">
|
||||
<Link href="/" className="navbar-brand">
|
||||
<Image src="/assets/img/CAT.png" width={89} height={89} alt="CatLink"/>
|
||||
</Link>
|
||||
<div className="collapse navbar-collapse">
|
||||
{user ? (
|
||||
<div className="ms-auto d-flex align-items-center">
|
||||
<Link href="/dashboard" className="me-3 d-flex align-items-center">
|
||||
<Image
|
||||
src={user.avatarUrl || '/assets/img/avatar-dhg.png'}
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-circle"
|
||||
alt="avatar"
|
||||
/>
|
||||
<span className="ms-2">{user.first_name || user.username}</span>
|
||||
</Link>
|
||||
<button className="btn btn-outline-danger" onClick={logout}>
|
||||
Выход
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||
Вход
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
199
frontend/linktree-frontend/src/app/dashboard/page.tsx
Normal file
199
frontend/linktree-frontend/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
// src/app/dashboard/page.tsx
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, Fragment } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ProfileCard } from '../components/ProfileCard'
|
||||
|
||||
interface UserProfile {
|
||||
avatar: string
|
||||
full_name: string
|
||||
email: string
|
||||
bio?: string
|
||||
last_login: string
|
||||
date_joined: string
|
||||
}
|
||||
|
||||
interface LinkItem {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number
|
||||
name: string
|
||||
links?: LinkItem[]
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
const [user, setUser] = useState<UserProfile | null>(null)
|
||||
const [groups, setGroups] = useState<Group[]>([])
|
||||
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
router.push('/auth/login')
|
||||
return
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// 1) Профиль
|
||||
const uRes = await fetch(`/api/auth/user/`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (uRes.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
router.push('/auth/login')
|
||||
return
|
||||
}
|
||||
if (!uRes.ok) throw new Error('Не удалось получить профиль')
|
||||
const userData: UserProfile = await uRes.json()
|
||||
|
||||
// 2) Группы
|
||||
const gRes = await fetch(`/api/groups/`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (gRes.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
router.push('/auth/login')
|
||||
return
|
||||
}
|
||||
if (!gRes.ok) throw new Error('Не удалось загрузить группы')
|
||||
const groupsData: Group[] = await gRes.json()
|
||||
|
||||
setUser(userData)
|
||||
setGroups(groupsData)
|
||||
} catch (err) {
|
||||
// на любую ошибку — редирект на логин
|
||||
console.error(err)
|
||||
localStorage.removeItem('token')
|
||||
router.push('/auth/login')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [router])
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center h-screen">Загрузка...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-8">
|
||||
<div className="container">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-xl-10 col-xxl-9">
|
||||
{/* Профиль пользователя */}
|
||||
{user && (
|
||||
<ProfileCard
|
||||
avatar={user.avatar}
|
||||
full_name={user.full_name}
|
||||
email={user.email}
|
||||
bio={user.bio}
|
||||
last_login={user.last_login}
|
||||
date_joined={user.date_joined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Секция групп ссылок */}
|
||||
<div className="card shadow mt-5">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 className="mb-0">Группы ссылок</h5>
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => {
|
||||
/* TODO: открыть модальное окно добавления группы */
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-plus-lg"></i> Добавить группу
|
||||
</button>
|
||||
</div>
|
||||
<div className="list-group list-group-flush">
|
||||
{groups.map((group) => (
|
||||
<Fragment key={group.id}>
|
||||
<div className="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() =>
|
||||
setExpandedGroup(
|
||||
expandedGroup === group.id ? null : group.id
|
||||
)
|
||||
}
|
||||
>
|
||||
{group.name}{' '}
|
||||
<span className="badge bg-secondary rounded-pill">
|
||||
{group.links?.length ?? 0}
|
||||
</span>
|
||||
</span>
|
||||
<div className="btn-group btn-group-sm">
|
||||
<button className="btn btn-outline-primary">
|
||||
<i className="bi bi-link-45deg"></i>
|
||||
</button>
|
||||
<button className="btn btn-outline-secondary">
|
||||
<i className="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button className="btn btn-outline-danger">
|
||||
<i className="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expandedGroup === group.id && (
|
||||
<div className="list-group-item bg-light">
|
||||
<ul className="mb-0 ps-3">
|
||||
{group.links?.map((link) => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="d-flex justify-content-between align-items-center mb-2"
|
||||
>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-grow-1"
|
||||
>
|
||||
{link.title}
|
||||
</a>
|
||||
<div className="btn-group btn-group-sm">
|
||||
<button className="btn btn-outline-secondary">
|
||||
<i className="bi bi-pencil-fill"></i>
|
||||
</button>
|
||||
<button className="btn btn-outline-danger">
|
||||
<i className="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<nav>
|
||||
<ul className="pagination pagination-sm justify-content-center mb-0">
|
||||
{[1, 2, 3].map((p) => (
|
||||
<li key={p} className="page-item">
|
||||
<Link href={`?page=${p}`} className="page-link">
|
||||
{p}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
BIN
frontend/linktree-frontend/src/app/favicon_ex.ico
Normal file
BIN
frontend/linktree-frontend/src/app/favicon_ex.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
173
frontend/linktree-frontend/src/app/globals.css
Normal file
173
frontend/linktree-frontend/src/app/globals.css
Normal file
@@ -0,0 +1,173 @@
|
||||
@import "tailwindcss";
|
||||
/* src/app/globals.css */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* дальше — ваши кастом-стили */
|
||||
@layer base {
|
||||
h1 {
|
||||
@apply text-3xl font-bold;
|
||||
}
|
||||
h2 {
|
||||
@apply text-2xl font-bold;
|
||||
}
|
||||
h3 {
|
||||
@apply text-xl font-bold;
|
||||
}
|
||||
p {
|
||||
@apply text-base;
|
||||
}
|
||||
}
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply bg-blue-500 text-white font-bold py-2 px-4 rounded;
|
||||
}
|
||||
.btn:hover {
|
||||
@apply bg-blue-700;
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.bg-custom {
|
||||
@apply bg-blue-100;
|
||||
}
|
||||
.text-custom {
|
||||
@apply text-blue-500;
|
||||
}
|
||||
}
|
||||
@layer screens {
|
||||
@screen sm {
|
||||
.text-sm {
|
||||
@apply text-xs;
|
||||
}
|
||||
}
|
||||
@screen md {
|
||||
.text-md {
|
||||
@apply text-base;
|
||||
}
|
||||
}
|
||||
@screen lg {
|
||||
.text-lg {
|
||||
@apply text-lg;
|
||||
}
|
||||
}
|
||||
}
|
||||
@layer forms {
|
||||
.form-input {
|
||||
@apply border-gray-300 rounded-md shadow-sm;
|
||||
}
|
||||
.form-input:focus {
|
||||
@apply border-blue-500 ring-blue-500;
|
||||
}
|
||||
}
|
||||
@layer typography {
|
||||
.prose {
|
||||
@apply prose-lg;
|
||||
}
|
||||
.prose h1 {
|
||||
@apply text-3xl font-bold;
|
||||
}
|
||||
.prose h2 {
|
||||
@apply text-2xl font-bold;
|
||||
}
|
||||
.prose h3 {
|
||||
@apply text-xl font-bold;
|
||||
}
|
||||
}
|
||||
@layer animations {
|
||||
.fade-in {
|
||||
@apply transition-opacity duration-500 ease-in;
|
||||
}
|
||||
.fade-out {
|
||||
@apply transition-opacity duration-500 ease-out;
|
||||
}
|
||||
}
|
||||
@layer transitions {
|
||||
.transition-all {
|
||||
@apply transition-all duration-300 ease-in-out;
|
||||
}
|
||||
.transition-colors {
|
||||
@apply transition-colors duration-300 ease-in-out;
|
||||
}
|
||||
}
|
||||
@layer shadows {
|
||||
.shadow-custom {
|
||||
@apply shadow-lg;
|
||||
}
|
||||
.shadow-none {
|
||||
@apply shadow-none;
|
||||
}
|
||||
}
|
||||
@layer borders {
|
||||
.border-custom {
|
||||
@apply border-2 border-blue-500;
|
||||
}
|
||||
.border-none {
|
||||
@apply border-none;
|
||||
}
|
||||
}
|
||||
@layer spacing {
|
||||
.p-custom {
|
||||
@apply p-4;
|
||||
}
|
||||
.m-custom {
|
||||
@apply m-4;
|
||||
}
|
||||
}
|
||||
@layer flex {
|
||||
.flex-center {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
.flex-column {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
}
|
||||
@layer grid {
|
||||
.grid-custom {
|
||||
@apply grid grid-cols-3 gap-4;
|
||||
}
|
||||
.grid-center {
|
||||
@apply grid place-items-center;
|
||||
}
|
||||
}
|
||||
@layer typography {
|
||||
.prose {
|
||||
@apply prose-lg;
|
||||
}
|
||||
.prose h1 {
|
||||
@apply text-3xl font-bold;
|
||||
}
|
||||
.prose h2 {
|
||||
@apply text-2xl font-bold;
|
||||
}
|
||||
.prose h3 {
|
||||
@apply text-xl font-bold;
|
||||
}
|
||||
}
|
||||
173
frontend/linktree-frontend/src/app/layout.tsx
Normal file
173
frontend/linktree-frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
// src/app/layout.tsx
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ReactNode } from "react";
|
||||
import Script from "next/script";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CatLink",
|
||||
description: "Ваши ссылки в одном месте",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
type="image/png"
|
||||
sizes="180x180"
|
||||
href="/assets/img/apple-touch-icon.png?h=0f5e29c1169e75a7003e818478b67caa"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="96x96"
|
||||
href="/assets/img/favicon-96x96.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="192x192"
|
||||
href="/assets/img/web-app-manifest-192x192.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="512x512"
|
||||
href="/assets/img/web-app-manifest-512x512.png?h=c8792ce927e01a494c4af48bed5dc5e3"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/assets/bootstrap/css/bootstrap.min.css?h=608a9825a1f76f674715160908e57785"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/assets/css/Lato.css?h=8253736d3a23b522f64b7e7d96d1d8ff"
|
||||
/>
|
||||
<link
|
||||
rel="manifest"
|
||||
href="/manifest.json?h=457941ffad3c027c946331c09a4d7d2f"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
|
||||
/>
|
||||
</head>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
{/* Header (fixed to top) */}
|
||||
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
||||
<div className="container">
|
||||
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||
<Image
|
||||
src="/assets/img/CAT.png"
|
||||
alt="CatLink"
|
||||
width={89}
|
||||
height={89}
|
||||
/>
|
||||
<span className="ms-2">CatLink</span>
|
||||
</Link>
|
||||
<button
|
||||
className="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navcol-1"
|
||||
/>
|
||||
<div className="collapse navbar-collapse" id="navcol-1">
|
||||
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
||||
<i className="fa fa-user"></i>
|
||||
<span className="d-none d-sm-inline"> Вход</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main content: добавляем дополнительный отступ сверху */}
|
||||
<main
|
||||
style={{
|
||||
paddingTop: "100px", // <-- тут увеличиваем интервал
|
||||
paddingBottom: "200px"
|
||||
}}
|
||||
// или через класс bootstrap: className="pt-5 pb-5"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
{/* Footer (fixed to bottom) */}
|
||||
<footer className="bg-light footer fixed-bottom border-top">
|
||||
<div className="container py-2">
|
||||
<div className="row">
|
||||
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
|
||||
<ul className="list-inline mb-1">
|
||||
<li className="list-inline-item">
|
||||
<Link href="#">About</Link>
|
||||
</li>
|
||||
<li className="list-inline-item">⋅</li>
|
||||
<li className="list-inline-item">
|
||||
<Link href="#">Contact</Link>
|
||||
</li>
|
||||
<li className="list-inline-item">⋅</li>
|
||||
<li className="list-inline-item">
|
||||
<Link href="#">Terms of Use</Link>
|
||||
</li>
|
||||
<li className="list-inline-item">⋅</li>
|
||||
<li className="list-inline-item">
|
||||
<Link href="#">Privacy Policy</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-muted small mb-0">© CatLink 2025</p>
|
||||
</div>
|
||||
<div className="col-lg-6 text-center text-lg-end">
|
||||
<ul className="list-inline mb-0">
|
||||
<li className="list-inline-item">
|
||||
<Link href="#">
|
||||
<i className="fa fa-facebook fa-lg fa-fw"></i>
|
||||
</Link>
|
||||
</li>
|
||||
<li className="list-inline-item">
|
||||
<Link href="#">
|
||||
<i className="fa fa-twitter fa-lg fa-fw"></i>
|
||||
</Link>
|
||||
</li>
|
||||
<li className="list-inline-item">
|
||||
<Link href="#">
|
||||
<i className="fa fa-instagram fa-lg fa-fw"></i>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Scripts */}
|
||||
<Script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
<Script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
75
frontend/linktree-frontend/src/app/page.tsx
Normal file
75
frontend/linktree-frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// src/app/page.tsx
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className="text-center text-white masthead"
|
||||
style={{
|
||||
background: "url('/assets/img/bg-masthead.jpg') no-repeat center center",
|
||||
backgroundSize: 'cover',
|
||||
}}
|
||||
>
|
||||
<div className="overlay"></div>
|
||||
<div className="container py-5">
|
||||
<div className="row">
|
||||
<div className="col-xl-9 mx-auto">
|
||||
<h1 className="mb-5">Ваши ссылки. Ваш стиль. Ваш CatLink.</h1>
|
||||
</div>
|
||||
<div className="col-md-10 col-lg-8 col-xl-7 mx-auto">
|
||||
<form className="d-flex">
|
||||
<input
|
||||
className="form-control form-control-lg me-2"
|
||||
type="email"
|
||||
placeholder="Введите электронную почту"
|
||||
/>
|
||||
<button className="btn btn-primary btn-lg" type="submit">
|
||||
Начать
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="text-center bg-light features-icons py-5">
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-lg-4 mb-4">
|
||||
<div className="features-icons-item">
|
||||
<div className="features-icons-icon mb-3">
|
||||
<img src="/assets/img/CAT.png" alt="CatLink" width={89} height={89} />
|
||||
</div>
|
||||
<h3>Публикация</h3>
|
||||
<p className="lead mb-0">
|
||||
Делитесь единой ссылкой catlinks.kr/ваше-имя в био, мессенджерах и письмах.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-4 mb-4">
|
||||
<div className="features-icons-item">
|
||||
<div className="features-icons-icon mb-3">
|
||||
<img src="/assets/img/CAT.png" alt="CatLink" width={89} height={89} />
|
||||
</div>
|
||||
<h3>Почему CatLink?</h3>
|
||||
<p className="lead mb-0">
|
||||
Повяжите свои миры одной «хвостовой» ссылкой.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-4 mb-4">
|
||||
<div className="features-icons-item">
|
||||
<div className="features-icons-icon mb-3">
|
||||
<img src="/assets/img/CAT.png" alt="CatLink" width={89} height={89} />
|
||||
</div>
|
||||
<h3>Разместите всё важное на одной ссылке</h3>
|
||||
<p className="lead mb-0">
|
||||
Идите дальше, как кошка: легко и грациозно.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
52
frontend/linktree-frontend/src/app/username/page.tsx
Normal file
52
frontend/linktree-frontend/src/app/username/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
// src/app/[username]/page.tsx
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface LinkItem {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export default async function UserLinksPage({ params }: { params: { username: string } }) {
|
||||
const { username } = params;
|
||||
// Серверный запрос к API. Замените API_URL на ваш бекенд
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/users/${username}/links`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (!res.ok) {
|
||||
return (
|
||||
<main className="max-w-md mx-auto p-6">
|
||||
<h1 className="text-2xl font-semibold mb-4">Пользователь не найден</h1>
|
||||
<p>Попробуйте другой логин.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
const links: LinkItem[] = await res.json();
|
||||
|
||||
return (
|
||||
<main className="max-w-md mx-auto p-6">
|
||||
<h1 className="text-3xl font-bold mb-6">Каталог ссылок: {username}</h1>
|
||||
{links.length === 0 ? (
|
||||
<p>У пользователя пока нет ссылок.</p>
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
{links.map((link) => (
|
||||
<li key={link.id} className="p-4 border rounded hover:shadow">
|
||||
<Link href={link.url} target="_blank" className="flex items-center space-x-3">
|
||||
{link.icon ? (
|
||||
<Image src={link.icon} alt={link.title} width={24} height={24} />
|
||||
) : (
|
||||
<span className="w-6 h-6 bg-gray-300 rounded-full" />
|
||||
)}
|
||||
<span className="text-lg font-medium">{link.title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user