first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 22:07:47 +03:00
commit 5285a0dd86
522 changed files with 41738 additions and 0 deletions

8
app/app.vue Normal file
View File

@@ -0,0 +1,8 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<script lang="ts" setup>
</script>

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
</script>
<template>
<footer class="footer-area padding-top font-f-2">
<div class="container">
<div class="row">
<div class="col-lg-4 col-sm-6">
<div class="single-footer mr50 hadding2">
<div class="site-logo home1-site-logo">
<a href="#"
><img src="/assets/img/logo/header-logo1.svg" alt="" />
</a>
<span class="font-f-2">Were hiring</span>
</div>
<div class="space30"></div>
<p class="font-f-2 font-16 line-height-26">
As a graphic designer, add motion to your skillset. Animate
whatever you create and tell your story in a magical way with
Artboard Studio.
</p>
</div>
</div>
<div class="col-lg col-sm-6 hadding2">
<div class="single-footer">
<h3 class="font-f-1 font-20 weight-700 line-height-20">
Discover
</h3>
<div>
<ul class="font-f-2 font-16 line-height-26 pera-c-1">
<li><a href="#">Learn</a></li>
<li><a href="blog.html">Blog</a></li>
<li><a href="#">Pricing</a></li>
<li><a href="#">Template Maker</a></li>
<li><a href="#">Mockup Generator</a></li>
</ul>
</div>
</div>
</div>
<div class="col-lg col-sm-6 hadding2">
<div class="single-footer">
<h3 class="font-f-2 font-20 weight-700 line-height-20">
Company
</h3>
<div>
<ul class="font-f-2 font-16 line-height-26 pera-c-1">
<li><a href="about.html">About us</a></li>
<li><a href="#">Licensing</a></li>
<li><a href="#">privacy Policy</a></li>
<li><a href="#">Terms of Use</a></li>
<li><a href="#">Refund Policy</a></li>
</ul>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 hadding2">
<div class="single-footer-contact">
<h3 class="font-f-2 font-20 weight-700 line-height-20">
Get in touch
</h3>
<div class="foonter-contact-1">
<div class="foonter-contact-icon-1">
<div class="">
<img src="/assets/img/icons/footer-icon-1.svg" alt="" />
</div>
</div>
<div class="foonter-contact-p">
<a href="tel:+910225850556">USA: +91 02 2585 0556</a>
<a href="tel:+610225850556">UK: +61 02 2585 0556</a>
</div>
</div>
<div class="foonter-contact-1">
<div class="foonter-contact-icon-1">
<div class="">
<img src="/assets/img/icons/footer-icon-2.svg" alt="" />
</div>
</div>
<div class="foonter-contact-p">
<a href="mailto:Contacthelp@Demoui.co"
>Contacthelp@Demoui.co</a
>
<a href="mailto:Info@Demoui.co">Info@Demoui.co</a>
</div>
</div>
</div>
</div>
</div>
<div class="row align-items-center copyright2">
<div class="col-lg-6">
<p class="font-16 weight-400 font-f-2 line-height-16 pera-c-1">
© 2023 Avigo by fleexstudio. All Rights Reserved.
</p>
</div>
<div class="col-lg-6 text-right">
<div class="social social1 comon-footer-icons">
<ul>
<li>
<a href="#"><i class="fa-brands fa-twitter"></i></a>
</li>
<li>
<a href="#"><i class="fa-brands fa-facebook-f"></i></a>
</li>
<li>
<a href="#"><i class="fa-brands fa-instagram"></i></a>
</li>
<li>
<a href="#"><i class="fa-brands fa-github"></i></a>
</li>
</ul>
</div>
</div>
</div>
</div>
</footer>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
</script>
<template>
<header>
<div class="header-area d-none d-lg-block" id="header">
<div class="container">
<div class="row">
<div class="col-12">
<div class="header-elements">
<div class="site-logo home1-site-logo">
<a href="#"
><img src="/assets/img/logo/header-logo1.svg" alt=""/>
</a>
<span>Were hiring</span>
</div>
<div class="main-menu-ex main-menu-ex1">
<ul>
<li class="has-dropdown"><a href="#">Home <i class="fa-solid fa-angle-down"></i></a>
<ul class="sub-menu">
<li class="has-dropdown has-dropdown1"><a href="index4.html">Multipage<span><i
class="fa-solid fa-angle-right"></i></span></a>
<ul class="sub-menu">
<li><a href="index.html">Real Estate</a></li>
<li><a href="index2.html">Management</a></li>
<li><a href="index3.html">App landing</a></li>
<li><a href="index4.html">Web Hosting </a></li>
<li><a href="index5.html">Consulting </a></li>
</ul>
</li>
<li class="has-dropdown has-dropdown1"><a href="index4.html">Landing Page<span><i
class="fa-solid fa-angle-right"></i></span></a>
<ul class="sub-menu">
<li><a href="single-index1.html">Real Estate</a></li>
<li><a href="single-index2.html">Management</a></li>
<li><a href="single-index3.html">App landing</a></li>
<li><a href="single-index4.html">Web Hosting </a></li>
<li><a href="single-index5.html">Consulting </a></li>
</ul>
</li>
</ul>
</li>
<li><a href="about.html">About Us</a>
</li>
<li class="has-dropdown"><a href="#">Service <i class="fa-solid fa-angle-down"></i></a>
<ul class="sub-menu">
<li class="has-dropdown has-dropdown1"><a href="index4.html">Our Service<span><i
class="fa-solid fa-angle-right"></i></span></a>
<ul class="sub-menu">
<li><a href="service.html">Our Service 1</a></li>
<li><a href="service2.html">Our Service 2</a></li>
</ul>
</li>
<li><a href="service-details.html">Service Details</a></li>
</ul>
</li>
<li class="dropdown-menu-parrent"><a href="#">Pages <i class="fa-solid fa-angle-down"></i></a>
<ul>
<li><a href="property.html">Property</a></li>
<li><a href="property-details.html">Property Details</a></li>
<li><a href="pricing-plan.html">Pricing Plan</a></li>
<li><a href="faq.html">FAQ</a></li>
<li><a href="team.html">Our Team</a></li>
</ul>
</li>
<li class="has-dropdown"><a href="#">Blog <i class="fa-solid fa-angle-down"></i></a>
<ul class="sub-menu">
<li class="has-dropdown has-dropdown1"><a href="blog.html">Blog<span><i
class="fa-solid fa-angle-right"></i></span></a>
<ul class="sub-menu">
<li><a href="blog.html">Our Blog</a></li>
<li><a href="blog-details.html">Blog Formets</a></li>
<li><a href="blog-sidebar-left.html">Blog Sidebar Left</a></li>
<li><a href="blog-sidebar-right.html">Blog Sidebar Right</a></li>
</ul>
</li>
<li><a href="blog-details.html">Single Blog</a></li>
</ul>
</li>
<li><a href="contact.html">Contact Us</a></li>
<li class="d-lg-none"><a href="#">Sign In / Sign up</a></li>
</ul>
</div>
<div class="header-site-btn">
<a href="#" class="home2-site-btn-1 font-f-2 weight-400 font-16 line-height-16">Sign In / Sign Up</a>
<a class="cta-btn font-f-2 weight-700" href="property.html">List Your Property</a>
</div>
<div class="mobile-menu-bar d-lg-none">
<i class="fas fa-bars"></i>
</div>
</div>
</div>
</div>
</div>
</div>
</header>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
</script>
<template>
<div class="mobile-header mobile-header-4 d-block d-lg-none ">
<div class="container-fluid">
<div class="col-12">
<div class="mobile-header-elements">
<div class="mobile-logo">
<a href="index.html"><img src="/assets/img/logo/header-logo1.svg" alt=""></a>
</div>
<div class="mobile-nav-icon">
<i class="fa-solid fa-bars"></i>
</div>
</div>
</div>
</div>
</div>
<div class="mobile-sidebar d-block d-lg-none">
<div class="menu-close">
<i class="fa-solid fa-xmark"></i>
</div>
<div class="mobile-nav">
<ul class="mobile-nav-list">
<li><a href="#">Home</a>
<ul class="sub-menu">
<li><a href="single-index1.html">Real Estate</a></li>
<li><a href="single-index2.html">Project Management</a></li>
<li><a href="single-index3.html">Appland</a></li>
<li><a href="single-index4.html">Hostika </a></li>
<li><a href="single-index5.html">Insurance </a></li>
</ul>
</li>
<li><a href="about.html">About Us</a></li>
<li><a href="#">Service</a>
<ul class="sub-menu">
<li><a href="service.html">Service</a></li>
<li><a href="service2.html">Service</a></li>
<li><a href="service-details.html">Service Details</a></li>
</ul>
</li>
<li><a href="#">Blog</a>
<ul class="sub-menu">
<li><a href="blog.html">Blog</a></li>
<li><a href="blog-left-bar.html">Blog Left</a></li>
<li><a href="blog-right-bar.html">Blog Right</a></li>
<li><a href="blog-details.html">Blog Details</a></li>
<li><a href="blog-single.html">Single Blog</a></li>
</ul>
</li>
<li><a href="#">Pages</a>
<ul class="sub-menu">
<li><a href="property.html">Property</a></li>
<li><a href="property-details.html">Property Details</a></li>
<li><a href="pricing-plan.html">Pricing Plan</a></li>
<li><a href="team.html">Our Team</a></li>
<li><a href="faq.html">Our Faq</a></li>
</ul>
</li>
<li><a href="contact.html">Contact Us</a></li>
<li><a href="#">Sign In / Sign up </a></li>
</ul>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
</script>
<template>
<div class="paginacontainer">
<div class="progress-wrap">
<svg class="progress-circle svg-content" width="100%" height="100%" viewBox="-1 -1 102 102">
<path d="M50,1 a49,49 0 0,1 0,98 a49,49 0 0,1 0,-98"/>
</svg>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
</script>
<template>
<div class="row text-center">
<div class="col-12 m-auto text-center">
<div class="theme-pagination text-center">
<ul>
<li><a href="#"><i class="fa-solid fa-angle-left"></i></a></li>
<li><a class="active" href="#">01</a></li>
<li><a href="#">02</a></li>
<li>...</li>
<li><a href="#">12</a></li>
<li><a href="#"><i class="fa-solid fa-angle-right"></i></a></li>
</ul>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
</script>
<template>
<div class="subsribe-all sp2">
<div class="container">
<div class="row align-items-center">
<div class="col-md-7">
<div class="all-subsribe-hadding">
<h1
class="font-f-2 weight-700 font-30 font-lg-45 line-height-30 line-height-lg-45 font-w"
>
Get the properties you're interested in delivered straight to
your inbox.
</h1>
</div>
</div>
<div class="col-md-5">
<div class="comon-subsribe-all-input">
<input type="email" placeholder="Enter your email here" />
<div class="subsribe-btn">
<div class="home2-btn">
<button type="submit"
class="font-18 line-height-30 weight-700 font-f-2 font-w"
>
Get Notified
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
</script>
<template>
<div class="single-widget widget_tag_cloud">
<h3 class="font-f-2">Tags</h3>
<div class="space10"></div>
<div class="tagcloud">
<a href="#" class="font-f-2">Healthcare</a>
<a href="#" class="font-f-2">Consult</a>
<a href="#" class="font-f-2">Cahless</a>
<a href="#" class="font-f-2">Cyberattacks</a>
<a href="#" class="font-f-2">Meeting</a>
<a href="#" class="font-f-2">Leadership</a>
<a href="#" class="font-f-2">Health Insurace</a>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import Paginate from "~/components/Paginate.vue";
</script>
<template>
<div class="col-lg-8">
<div class="blogs padding-right">
<article class="post-blog post-format-image">
<div class="single-blog">
<div class="execution-images">
<div class="execution-img1">
<img src="/assets/img/shapes/service-details-shaop-bg.svg" alt="">
</div>
<div class="execution-img2">
<img src="/assets/img/image/service-details-img.png" alt="">
</div>
<div class="execution-img3">
<img src="/assets/img/shapes/service-details-shape.svg" alt="">
</div>
</div>
<div class="author-bio mt-4 mb-3">
<div class="author-name">
<a href="#" class="font-f-2"><i class="fa-solid fa-user"></i> Miro koyetin</a>
</div>
<div class="blog-category">
<a href="#" class="font-f-2"><i class="fa-solid fa-tag"></i>Lifestyle</a>
</div>
<div class="blog-date">
<a href="#" class="font-f-2"><i class="fa-solid fa-calendar-days"></i>18 March 2023</a>
</div>
</div>
<div class="hadding2">
<h4 class="font-f-2 font-24 line-height-28 weight-500"><a href="blog-single.html">We leverage
technology to solve challenges for your business.</a></h4>
<div class="space32"></div>
<p class="details-p-hadding font-18 line-height-18 weight-500 font-f-2">Client completes business
questionnaire</p>
<div class="space16"></div>
<p class="font-f-2 weight-400 line-height-28 font-16">After an initial complimentary phone call
consultation, for new and potential clients we then conduct an initial assessment, which reviews
and covers the current or potential business opportunities to made marketing strategies,
marketplace competitive analysis, social media presence and other specific an assessments as
needed to determine the best recommendations to improve client productivity, ensure efficiency,
and generate revenue immediately. This aids in the discovery process for both client and
consultant hen have a two hour meeting to discuss your business goals and provide.</p>
<div class="space40"></div>
<p class="details-p-hadding font-18 line-height-18 weight-500 font-f-2">Client completes business
questionnaire</p>
<div class="space24"></div>
<p class="font-f-2 weight-400 line-height-28 font-16">After an initial complimentary phone call
consultation, for new and potential clients we then conduct an initial assessment, which reviews
and covers the current or potential business opportunities to made marketing strategies,
marketplace competitive analysis, social media presence and other specific an assessments as
needed to determine the best recommendations to improve client productivity, ensure efficiency,
and generate revenue immediately. This aids in the discovery process for both client and
consultant hen have a two hour meeting to discuss your business goals and provide.</p>
</div>
</div>
</article>
</div>
<div class="space60"></div>
<Paginate/>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
</script>
<template>
<div class="pages-hero">
<div class="container">
<div class="row">
<div class="col-md-12">
<div class="page-hadding hadding2">
<h1
class="font-f-2 weight-700 font-40 font-lg-60 line-height-48 line-height-lg-65">
Blog <span class="after">sidebar right</span>
</h1>
<p class="font-f-2 font-16 weight-500 line-height-16"><a href="index.html">Home</a> <span><i
class="fa-solid fa-angle-right"></i></span> Blog <span><i class="fa-solid fa-angle-right"></i></span>
Blog Details </p>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
</script>
<template>
<div id="preloader">
<div id="loader"></div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
</script>
<template>
<div class="single-widget widget_categories">
<h3 class="font-f-2">Blog Category</h3>
<div class="space10"></div>
<div class="blog-category-list">
<ul>
<li><a class="active font-f-2" href="#">Excutive consultant (10) <span><i
class="fa-solid fa-angle-right"></i></span></a></li>
<li><a href="#" class="font-f-2">Marketing & advertising (12) <span><i
class="fa-solid fa-angle-right"></i></span></a></li>
<li><a href="#" class="font-f-2">Accounbility & execution (08)<span><i
class="fa-solid fa-angle-right"></i></span></a></li>
<li><a href="#" class="font-f-2">Business planning (21)<span><i class="fa-solid fa-angle-right"></i></span></a>
</li>
<li><a href="#" class="font-f-2">Privet planning (13)<span><i
class="fa-solid fa-angle-right"></i></span></a></li>
<li><a href="#" class="font-f-2">Mutual invest planning (06)<span><i
class="fa-solid fa-angle-right"></i></span></a></li>
<li><a href="#" class="font-f-2">Project managment (1)<span><i class="fa-solid fa-angle-right"></i></span></a>
</li>
</ul>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
</script>
<template>
<div class="single-widget single-widget-post-sidebar">
<h3 class="font-f-2">Search</h3>
<div class="recent-post">
<div class="">
<div class="recent-img">
<img src="/assets/img/image/blog-details-side1.png" alt="">
</div>
</div>
<div class="recent-post-content">
<div class="blog-date-time">
<ul class="blog-date">
<li><img src="/assets/img/icons/date.svg" alt=""> 11/02/2022</li>
</ul>
</div>
<h6><a href="blog-details.html" class="font-f-2">Leadership Burnout: What causes it and how avoid
it.</a></h6>
</div>
</div>
<div class="recent-post">
<div class="">
<div class="recent-img">
<img src="/assets/img/image/blog-details-side2.png" alt="">
</div>
</div>
<div class="recent-post-content">
<div class="blog-date-time">
<ul class="blog-date">
<li><img src="/assets/img/icons/date.svg" alt=""> 11/02/2022</li>
</ul>
</div>
<h6><a href="blog-details.html" class="font-f-2">New Consulting For All Kind Offer Finance...</a>
</h6>
</div>
</div>
<div class="recent-post">
<div class="">
<div class="recent-img">
<img src="/assets/img/image/blog-details-side3.png" alt="">
</div>
</div>
<div class="recent-post-content">
<div class="blog-date-time">
<ul class="blog-date">
<li><img src="/assets/img/icons/date.svg" alt=""> 11/02/2022</li>
</ul>
</div>
<h6><a href="blog-details.html" class="font-f-2">What we are capable to usually discovered...</a>
</h6>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
</script>
<template>
<div class="single-widget widget_search">
<h3 class="font-f-2">Search</h3>
<div class="search-form-widget">
<form action="#">
<input type="search" placeholder="Type keyword here">
<button type="submit" class="search-icon"><i class="fa-solid fa-magnifying-glass"></i></button>
</form>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
</script>
<template>
<div class="sidebar-img-box-all text-center">
<div class="sidebar-img">
<img src="/assets/img/image/sidebar-box-img.png" alt="">
</div>
<div class="sidebar-box-hadding">
<div class="space16"></div>
<h4><a href="#" class="font-f-2 font-20 line-height-20 weight-500 font-w">Alex Blake</a></h4>
<div class="space24"></div>
<p class="font-f-2 font-16 line-height-26 weight-400 font-w">When it comes to business, listen to
Henry David Thoreau: things usually dont happen overnight instead, to find success takes a lot of
time, effort, and courage. Opus includes everything you need to build a beautiful website.</p>
</div>
<div class="details-side-box-icon">
<div class="space24"></div>
<ul>
<li><a href="#"><i class="fa-brands fa-twitter"></i></a></li>
<li><a href="#"><i class="fa-brands fa-facebook-f"></i></a></li>
<li><a href="#"><i class="fa-brands fa-instagram"></i></a></li>
</ul>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
</script>
<template>
<div class="single-widget widget_tag_cloud">
<h3 class="font-f-2">Tags</h3>
<div class="space10"></div>
<div class="tagcloud">
<a href="#" class="font-f-2">Healthcare</a>
<a href="#" class="font-f-2">Consult</a>
<a href="#" class="font-f-2">Cahless</a>
<a href="#" class="font-f-2">Cyberattacks</a>
<a href="#" class="font-f-2">Meeting</a>
<a href="#" class="font-f-2">Leadership</a>
<a href="#" class="font-f-2">Health Insurace</a>
</div>
</div>
</template>
<style scoped>
</style>

88
app/layouts/admin.vue Normal file
View File

@@ -0,0 +1,88 @@
<template>
<div class="d-flex" id="wrapper">
<!-- Sidebar -->
<div class="bg-dark text-white p-3" id="sidebar-wrapper" style="min-width: 250px; min-height: 100vh;">
<div class="sidebar-heading text-center py-4 fs-4 fw-bold border-bottom">Admin Panel</div>
<div class="list-group list-group-flush my-3">
<NuxtLink to="/admin" class="list-group-item list-group-item-action bg-transparent text-white border-0">
<i class="fas fa-tachometer-alt me-2"></i> Dashboard
</NuxtLink>
<NuxtLink to="/admin/settings"
class="list-group-item list-group-item-action bg-transparent text-white border-0">
<i class="fas fa-cogs me-2"></i> Settings
</NuxtLink>
<NuxtLink to="/admin/security"
class="list-group-item list-group-item-action bg-transparent text-white border-0">
<i class="fas fa-shield-alt me-2"></i> Güvenlik &
</NuxtLink>
<NuxtLink to="/admin/users"
class="list-group-item list-group-item-action bg-transparent text-white border-0">
<i class="fas fa-users me-2"></i> Kullanıcılar
</NuxtLink>
<div class="sidebar-heading text-white-50 mt-3 mb-1 small text-uppercase">Blog</div>
<NuxtLink to="/admin/blog/categories"
class="list-group-item list-group-item-action bg-transparent text-white border-0">
<i class="fas fa-tags me-2"></i> Kategoriler
</NuxtLink>
<NuxtLink to="/admin/blog/tags"
class="list-group-item list-group-item-action bg-transparent text-white border-0">
<i class="fas fa-hashtag me-2"></i> Etiketler
</NuxtLink>
<NuxtLink to="/admin/blog/posts"
class="list-group-item list-group-item-action bg-transparent text-white border-0">
<i class="fas fa-newspaper me-2"></i> Postlar
</NuxtLink>
<NuxtLink to="/" class="list-group-item list-group-item-action bg-transparent text-white border-0 mt-5">
<i class="fas fa-home me-2"></i> Siteye Dön
</NuxtLink>
<button @click="handleLogout"
class="list-group-item list-group-item-action bg-transparent text-danger border-0">
<i class="fas fa-sign-out-alt me-2"></i> Çıkış
</button>
</div>
</div>
<!-- Page Content -->
<div id="page-content-wrapper" class="w-100">
<nav class="navbar navbar-expand-lg navbar-light bg-light border-bottom px-4">
<button class="btn btn-primary" id="menu-toggle">Toggle Menu</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ms-auto mt-2 mt-lg-0">
<li class="nav-item active">
<span class="nav-link">Hoşgeldin, Admin</span>
</li>
</ul>
</div>
</nav>
<div class="container-fluid px-4 py-4">
<slot />
</div>
</div>
</div>
</template>
<script setup lang="ts">
const { signOut } = useAuth();
const handleLogout = async () => {
await signOut({ callbackUrl: '/auth/login' });
};
</script>
<style scoped>
#sidebar-wrapper {
transition: all 0.3s;
}
.list-group-item-action:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
color: #fff !important;
}
.router-link-active {
background-color: rgba(255, 255, 255, 0.2) !important;
border-radius: 5px;
}
</style>

72
app/layouts/default.vue Normal file
View File

@@ -0,0 +1,72 @@
<template>
<div>
<slot />
<div class="progress-wrap">
<svg class="progress-circle svg-content" width="100%" height="100%" viewBox="-1 -1 102 102">
<path d="M50,1 a49,49 0 0,1 0,98 a49,49 0 0,1 0,-98"/>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useHeroStore } from '@/stores/hero';
import { useSettingStore } from '@/stores/setting';
const heroStore = useHeroStore();
const settingStore = useSettingStore();
useHead(computed(() => {
const title = settingStore.settings?.title ?? heroStore.hero?.title ?? 'Blog details Sidebar Right || Avigo Multipurpose Theme';
const meta: Array<Record<string, string>> = [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0' },
];
if (settingStore.settings?.meta_description) {
meta.push({ name: 'description', content: settingStore.settings.meta_description });
}
return {
title,
meta
}
}));
onMounted(() => {
// page-progress
const progressPath = document.querySelector(".progress-wrap path") as SVGPathElement | null;
if (progressPath) {
const pathLength = progressPath.getTotalLength();
// use webkitTransition (lowercase) to satisfy TypeScript and cast style where needed
progressPath.style.transition = (progressPath.style as any).webkitTransition = "none";
progressPath.style.strokeDasharray = pathLength + " " + pathLength;
progressPath.style.strokeDashoffset = String(pathLength);
progressPath.getBoundingClientRect();
progressPath.style.transition = (progressPath.style as any).webkitTransition = "stroke-dashoffset 10ms linear";
const updateProgress = () => {
const scroll = $(window).scrollTop();
const height = $(document).height() - $(window).height();
const prog = pathLength - (scroll * pathLength) / height;
progressPath!.style.strokeDashoffset = String(prog);
};
updateProgress();
$(window).scroll(updateProgress);
const offset = 50;
const duration = 550;
jQuery(window).on("scroll", () => {
if (jQuery(window).scrollTop() > offset) {
jQuery(".progress-wrap").addClass("active-progress");
} else {
jQuery(".progress-wrap").removeClass("active-progress");
}
});
jQuery(".progress-wrap").on("click", (event: any) => {
event.preventDefault();
jQuery("html, body").animate({ scrollTop: 0 }, duration);
return false;
});
}
})
</script>

11
app/middleware/admin.ts Normal file
View File

@@ -0,0 +1,11 @@
export default defineNuxtRouteMiddleware((to, from) => {
const { status, data } = useAuth();
if (status.value !== 'authenticated') {
return navigateTo('/auth/login?callbackUrl=' + to.fullPath);
}
if (!data.value?.user?.is_admin) {
return navigateTo('/');
}
});

View File

@@ -0,0 +1,8 @@
// Middleware to redirect authenticated users away from guest-only pages
export default defineNuxtRouteMiddleware((to, from) => {
const { status } = useAuth();
if (status.value === 'authenticated') {
return navigateTo('/');
}
});

View File

@@ -0,0 +1,350 @@
<template>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Kategori Yönetimi</h2>
</div>
<!-- Filter / Search -->
<div class="card mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<select v-model="filterStatus" class="form-select" @change="fetchCategories(1)">
<option value="">Aktif Kategoriler</option>
<option value="only">Silinenler (Çöp Kutusu)</option>
<option value="with">Tümü (Silinenler Dahil)</option>
</select>
</div>
</div>
</div>
</div>
<!-- Categories List Card -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center bg-white">
<h5 class="mb-0">Kategoriler</h5>
<button class="btn btn-success btn-sm" @click="openModal(null)">
<i class="fas fa-plus me-2"></i> Yeni Kategori
</button>
</div>
<div class="card-body">
<div v-if="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Yükleniyor...</span>
</div>
</div>
<div v-else-if="categories.length === 0" class="text-center py-5 text-muted">
Kayıt bulunamadı.
</div>
<div v-else class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>ID</th>
<th>Başlık</th>
<th>Slug</th>
<th>ıklama</th>
<th>Durum</th>
<th class="text-end">İşlemler</th>
</tr>
</thead>
<tbody>
<tr v-for="category in categories" :key="category.ID">
<td>{{ category.ID }}</td>
<td>
<!-- Indentation for hierarchy if available, otherwise flat -->
<div :style="{ paddingLeft: (category.parent_id ? 20 : 0) + 'px' }">
<span v-if="category.parent_id" class="text-muted me-1"></span>
{{ category.title }}
</div>
</td>
<td><span class="badge bg-light text-dark">{{ category.slug }}</span></td>
<td>{{ category.description }}</td>
<td>
<span v-if="category.DeletedAt" class="badge bg-danger">Silindi</span>
<span v-else class="badge bg-success">Aktif</span>
</td>
<td class="text-end">
<div v-if="!category.DeletedAt">
<button class="btn btn-sm btn-outline-primary me-2" @click="openModal(category)">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-sm btn-outline-danger" @click="deleteCategory(category.ID)">
<i class="fas fa-trash"></i>
</button>
</div>
<div v-else>
<button class="btn btn-sm btn-outline-success me-2" @click="restoreCategory(category.ID)">
<i class="fas fa-trash-restore"></i>
</button>
<button class="btn btn-sm btn-outline-danger" @click="hardDeleteCategory(category.ID)">
<i class="fas fa-times-circle"></i> Kalıcı Sil
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<nav v-if="pagination.total > pagination.per_page" class="mt-4">
<ul class="pagination justify-content-center">
<li class="page-item" :class="{ disabled: pagination.page === 1 }">
<button class="page-link" @click="fetchCategories(pagination.page - 1)">Önceki</button>
</li>
<li v-for="page in Math.ceil(pagination.total / pagination.per_page)" :key="page"
class="page-item" :class="{ active: pagination.page === page }">
<button class="page-link" @click="fetchCategories(page)">{{ page }}</button>
</li>
<li class="page-item" :class="{ disabled: pagination.page >= Math.ceil(pagination.total / pagination.per_page) }">
<button class="page-link" @click="fetchCategories(pagination.page + 1)">Sonraki</button>
</li>
</ul>
</nav>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="modal fade" id="categoryModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ isEditing ? 'Kategoriyi Düzenle' : 'Yeni Kategori' }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form @submit.prevent="saveCategory">
<div class="mb-3">
<label class="form-label">Başlık</label>
<input v-model="formData.title" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">ıklama</label>
<textarea v-model="formData.description" class="form-control" rows="3"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Üst Kategori (Opsiyonel)</label>
<select v-model="formData.parent_id" class="form-select">
<option :value="null">Ana Kategori</option>
<!-- Only show active categories as parents to avoid confusion -->
<option v-for="cat in activeCategories" :key="cat.ID" :value="cat.ID"
:disabled="cat.ID === formData.ID">
{{ cat.title }}
</option>
</select>
</div>
<div class="text-end">
<button type="button" class="btn btn-secondary me-2"
data-bs-dismiss="modal">İptal</button>
<button type="submit" class="btn btn-primary" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
Kaydet
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Category } from '~~/types/category';
import Swal from 'sweetalert2';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
const config = useRuntimeConfig();
const { data: authData } = useAuth();
const categories = ref<Category[]>([]);
const activeCategories = ref<Category[]>([]); // For parent selection
const loading = ref(false);
const isEditing = ref(false);
const formData = ref<Partial<Category>>({});
const filterStatus = ref(''); // '' = active, 'only' = trashed, 'with' = all
let categoryModal: any = null;
const pagination = ref({
page: 1,
per_page: 10,
total: 0
});
// -- API --
const fetchCategories = async (page = 1) => {
loading.value = true;
try {
const query: any = { page }; // Pagination if supported by admin endpoint
if (filterStatus.value) {
query.trashed = filterStatus.value;
}
// Use Admin endpoint for management
const url = '/api/v1/admin/categories';
const res = await $fetch<any>(url, {
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
query
});
if (res.data) {
categories.value = res.data;
pagination.value = res.meta || { page: 1, per_page: 100, total: res.data.length }; // Fallback if no meta
} else if (Array.isArray(res)) {
// Fallback for non-paginated response (if API changes or for active list)
categories.value = res;
pagination.value = { page: 1, per_page: 100, total: res.length };
} else {
categories.value = [];
}
// Fetch active categories for parent selection (always needs active ones)
if (filterStatus.value === '') {
activeCategories.value = categories.value;
} else {
// If we are viewing trashed, we still need active categories for the dropdown
// We can fetch them separately/lightweight if needed, or just allow 'null' parent
// checking if we have them cached or fetch explicitly
// For now, let's just fetch active ones if we don't have them
const activeRes = await $fetch<Category[]>('/api/v1/categories', {
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
activeCategories.value = activeRes || [];
}
} catch (e) {
console.error(e);
categories.value = [];
} finally {
loading.value = false;
}
};
const openModal = (category: Category | null) => {
isEditing.value = !!category;
formData.value = category ? { ...category } : { title: '', description: '', parent_id: null };
// Check if modal instance exists
const el = document.getElementById('categoryModal');
if (el) {
// @ts-ignore
categoryModal = new bootstrap.Modal(el);
categoryModal.show();
}
};
const saveCategory = async () => {
loading.value = true;
try {
const url = isEditing.value
? `/api/v1/categories/${formData.value.ID}`
: '/api/v1/categories';
const method = isEditing.value ? 'PUT' : 'POST';
await $fetch(url, {
method,
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
body: formData.value
});
Swal.fire('Başarılı', `Kategori ${isEditing.value ? 'güncellendi' : 'eklendi'}.`, 'success');
if (categoryModal) categoryModal.hide();
fetchCategories(pagination.value.page);
} catch (error) {
console.error(error);
Swal.fire('Hata', 'İşlem başarısız.', 'error');
} finally {
loading.value = false;
}
};
const deleteCategory = async (id: number) => {
const result = await Swal.fire({
title: 'Emin misiniz?',
text: "Kategori silinecek (Çöp kutusuna taşınacak).",
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Evet, Sil',
cancelButtonText: 'İptal',
confirmButtonColor: '#d33'
});
if (!result.isConfirmed) return;
try {
await $fetch(`/api/v1/categories/${id}`, {
method: 'DELETE',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Silindi', 'Kategori silindi.', 'success');
fetchCategories(pagination.value.page);
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Silme işlemi başarısız.', 'error');
}
};
const restoreCategory = async (id: number) => {
try {
await $fetch(`/api/v1/admin/categories/${id}/restore`, {
method: 'POST',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Başarılı', 'Kategori geri yüklendi.', 'success');
fetchCategories(pagination.value.page);
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Geri yükleme başarısız.', 'error');
}
};
const hardDeleteCategory = async (id: number) => {
const result = await Swal.fire({
title: 'Kalıcı Olarak Sil?',
text: "Bu işlem geri alınamaz!",
icon: 'error',
showCancelButton: true,
confirmButtonText: 'Evet, Kalıcı Sil',
cancelButtonText: 'İptal',
confirmButtonColor: '#d33'
});
if (!result.isConfirmed) return;
try {
await $fetch(`/api/v1/admin/categories/${id}/hard`, {
method: 'DELETE',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Silindi', 'Kategori kalıcı olarak silindi.', 'success');
fetchCategories(pagination.value.page);
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Silme işlemi başarısız.', 'error');
}
};
onMounted(() => {
fetchCategories();
});
</script>

View File

@@ -0,0 +1,325 @@
<template>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Gönderi Düzenle</h2>
<NuxtLink to="/admin/blog/posts" class="btn btn-secondary">
<i class="fas fa-arrow-left me-2"></i> Geri Dön
</NuxtLink>
</div>
<div v-if="loadingData" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Yükleniyor...</span>
</div>
</div>
<div v-else class="card">
<div class="card-body">
<form @submit.prevent="updatePost">
<div class="row">
<div class="col-md-8">
<!-- Title -->
<div class="mb-3">
<label class="form-label">Başlık</label>
<input v-model="formData.title" type="text" class="form-control" required>
</div>
<!-- Content -->
<div class="mb-3">
<label class="form-label">İçerik</label>
<textarea v-model="formData.content" class="form-control" rows="10" required></textarea>
<div class="form-text">HTML içeriği girebilirsiniz.</div>
</div>
<!-- Tags -->
<div class="card mb-3">
<div class="card-header">Etiketler</div>
<div class="card-body" style="max-height: 200px; overflow-y: auto;">
<div v-if="availableTags.length === 0" class="text-muted small">Etiket bulunamadı.</div>
<div v-for="tag in availableTags" :key="tag.ID" class="form-check">
<input class="form-check-input" type="checkbox" :value="tag.ID" v-model="selectedTags" :id="`tag-${tag.ID}`">
<label class="form-check-label" :for="`tag-${tag.ID}`">
{{ tag.name }}
</label>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<!-- Categories -->
<div class="card mb-3">
<div class="card-header">Kategoriler</div>
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
<div v-for="cat in categories" :key="cat.ID" class="form-check">
<input class="form-check-input" type="checkbox" :value="cat.ID" v-model="formData.category_ids" :id="`cat-${cat.ID}`">
<label class="form-check-label" :for="`cat-${cat.ID}`">
{{ cat.title }}
</label>
</div>
</div>
</div>
<!-- Image Upload & Options -->
<div class="card mb-3">
<div class="card-header">Öne Çıkan Görsel</div>
<div class="card-body">
<div class="mb-3 text-center">
<div v-if="imagePreview" class="mb-3">
<img :src="imagePreview" class="img-fluid rounded" style="max-height: 200px;">
<button type="button" class="btn btn-sm btn-danger mt-2" @click="removeImage">Değiştir / Kaldır</button>
</div>
<div v-else>
<div class="mb-3">
<i class="fas fa-image fa-3x text-muted"></i>
</div>
<input type="file" class="form-control" @change="handleFileUpload" accept="image/*">
</div>
</div>
<div v-if="imageFile">
<hr>
<h6>Resim Ayarları</h6>
<div class="row g-2">
<div class="col-6">
<label class="form-label small">Genişlik (px)</label>
<input v-model="imageOptions.width" type="number" class="form-control form-control-sm" placeholder="Opsiyonel">
</div>
<div class="col-6">
<label class="form-label small">Yükseklik (px)</label>
<input v-model="imageOptions.height" type="number" class="form-control form-control-sm" placeholder="Opsiyonel">
</div>
<div class="col-6">
<label class="form-label small">Kalite (1-100)</label>
<input v-model="imageOptions.quality" type="number" class="form-control form-control-sm" min="1" max="100">
</div>
<div class="col-6">
<label class="form-label small">Format</label>
<select v-model="imageOptions.format" class="form-select form-select-sm">
<option value="jpeg">JPEG</option>
<option value="png">PNG</option>
<option value="webp">WebP</option>
<option value="avif">AVIF</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Update Button -->
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary" :disabled="saving">
<span v-if="saving" class="spinner-border spinner-border-sm me-2"></span>
{{ saving ? 'Güncelleniyor...' : 'Güncelle' }}
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Category } from '~~/types/category';
import type { Post } from '~~/types/post';
import type { Tag, TagResponse } from '~~/types/tag';
import Swal from 'sweetalert2';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
const config = useRuntimeConfig();
const { data: authData } = useAuth();
const route = useRoute();
const router = useRouter();
const loadingData = ref(true);
const saving = ref(false);
const categories = ref<Category[]>([]);
const availableTags = ref<Tag[]>([]);
const selectedTags = ref<number[]>([]);
const imageFile = ref<File | null>(null);
const imagePreview = ref<string | null>(null);
const formData = ref({
title: '',
content: '',
category_ids: [] as number[],
});
const imageOptions = ref({
width: null as number | null,
height: null as number | null,
quality: 80,
format: 'jpeg'
});
const postId = route.params.id as string;
const fetchData = async () => {
try {
const [catRes, tagRes] = await Promise.all([
$fetch<Category[]>('/api/v1/categories', {
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
}),
$fetch<TagResponse>('/api/v1/admin/tags', { // Fetching all tags
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
params: { limit: 100 }
})
]);
categories.value = catRes || [];
availableTags.value = tagRes?.data || [];
// After fetching data, fetch post
await fetchPost();
} catch (e) {
console.error('Veriler yüklenemedi', e);
loadingData.value = false;
}
};
const fetchPost = async () => {
try {
const res = await $fetch<Post>(`/api/v1/posts/${postId}`, {
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
if (res) {
formData.value.title = res.title;
formData.value.content = res.content;
formData.value.category_ids = res.categories?.map(c => c.ID) || [];
// Pre-select tags
if (res.tags) {
selectedTags.value = res.tags.map(t => t.ID);
}
// Image
if (res.images) {
// Should parse if JSON or string
let imgPath = res.images;
if (imgPath.startsWith('[') || imgPath.startsWith('{')) {
try {
const parsed = JSON.parse(imgPath);
if (Array.isArray(parsed) && parsed.length > 0) imgPath = parsed[0];
} catch(e) {}
}
imagePreview.value = getImageUrl(imgPath);
}
}
} catch (e) {
console.error('Post yüklenemedi', e);
Swal.fire('Hata', 'Gönderi bulunamadı veya yüklenemedi.', 'error');
router.push('/admin/blog/posts');
} finally {
loadingData.value = false;
}
};
const getImageUrl = (path: string) => {
if (path.startsWith('http')) return path;
if (path.startsWith('/')) return `${config.public.BASE_API_URL}${path}`;
return `${config.public.BASE_API_URL}/${path}`;
};
const handleFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
const file = target.files[0];
imageFile.value = file;
imagePreview.value = URL.createObjectURL(file);
// Default format based on uploaded file or keep jpeg
if (file.type === 'image/png') imageOptions.value.format = 'png';
else if (file.type === 'image/webp') imageOptions.value.format = 'webp';
else imageOptions.value.format = 'jpeg';
}
};
const removeImage = () => {
imageFile.value = null;
imagePreview.value = null;
};
const updatePost = async () => {
saving.value = true;
try {
const data = new FormData();
data.append('title', formData.value.title);
data.append('content', formData.value.content);
if (formData.value.category_ids.length > 0) {
data.append('category_ids', formData.value.category_ids.join(','));
}
if (selectedTags.value.length > 0) {
data.append('tag_ids', selectedTags.value.join(','));
}
// Optimize & Append Image if exists
if (imageFile.value) {
const optimizeData = new FormData();
optimizeData.append('file', imageFile.value);
if (imageOptions.value.width) optimizeData.append('width', imageOptions.value.width.toString());
if (imageOptions.value.height) optimizeData.append('height', imageOptions.value.height.toString());
optimizeData.append('quality', imageOptions.value.quality.toString());
optimizeData.append('format', imageOptions.value.format);
// Fetch as Blob from server/api/optimize
const optimizedBlob = await $fetch<Blob>('/api/optimize', {
method: 'POST',
body: optimizeData,
responseType: 'blob'
});
const ext = imageOptions.value.format === 'jpg' ? 'jpeg' : imageOptions.value.format;
const filename = `img-${Date.now()}.${ext === 'jpeg' ? 'jpg' : ext}`;
const optimizedFile = new File([optimizedBlob], filename, { type: `image/${ext}` });
data.append('images', optimizedFile);
}
await $fetch(`/api/v1/posts/${postId}`, {
method: 'PUT',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
body: data
});
Swal.fire({
title: 'Başarılı',
text: 'Gönderi güncellendi.',
icon: 'success',
showCancelButton: true,
confirmButtonText: 'Listeye Dön',
cancelButtonText: 'Kalsın'
}).then((result) => {
if (result.isConfirmed) {
router.push('/admin/blog/posts');
}
});
} catch (error: any) {
console.error(error);
Swal.fire('Hata', error.data?.message || 'Bir hata oluştu.', 'error');
} finally {
saving.value = false;
}
};
onMounted(() => {
fetchData();
});
</script>

View File

@@ -0,0 +1,253 @@
<template>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Yeni Gönderi Ekle</h2>
<NuxtLink to="/admin/blog/posts" class="btn btn-secondary">
<i class="fas fa-arrow-left me-2"></i> Geri Dön
</NuxtLink>
</div>
<div class="card">
<div class="card-body">
<form @submit.prevent="savePost">
<div class="row">
<div class="col-md-8">
<!-- Title -->
<div class="mb-3">
<label class="form-label">Başlık</label>
<input v-model="formData.title" type="text" class="form-control" required>
</div>
<!-- Content -->
<div class="mb-3">
<label class="form-label">İçerik</label>
<textarea v-model="formData.content" class="form-control" rows="10" required></textarea>
<div class="form-text">HTML içeriği girebilirsiniz.</div>
</div>
<!-- Tags -->
<div class="card mb-3">
<div class="card-header">Etiketler</div>
<div class="card-body" style="max-height: 200px; overflow-y: auto;">
<div v-if="availableTags.length === 0" class="text-muted small">Etiket bulunamadı.</div>
<div v-for="tag in availableTags" :key="tag.ID" class="form-check">
<input class="form-check-input" type="checkbox" :value="tag.ID" v-model="selectedTags" :id="`tag-${tag.ID}`">
<label class="form-check-label" :for="`tag-${tag.ID}`">
{{ tag.name }}
</label>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<!-- Categories -->
<div class="card mb-3">
<div class="card-header">Kategoriler</div>
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
<div v-if="categories.length === 0" class="text-muted small">Kategori bulunamadı.</div>
<div v-for="cat in categories" :key="cat.ID" class="form-check">
<input class="form-check-input" type="checkbox" :value="cat.ID" v-model="formData.category_ids" :id="`cat-${cat.ID}`">
<label class="form-check-label" :for="`cat-${cat.ID}`">
{{ cat.title }}
</label>
</div>
</div>
</div>
<!-- Image Upload & Options -->
<div class="card mb-3">
<div class="card-header">Öne Çıkan Görsel</div>
<div class="card-body">
<div class="mb-3 text-center">
<div v-if="imagePreview" class="mb-3">
<img :src="imagePreview" class="img-fluid rounded" style="max-height: 200px;">
<button type="button" class="btn btn-sm btn-danger mt-2" @click="removeImage">Kaldır</button>
</div>
<div v-else>
<div class="mb-3">
<i class="fas fa-image fa-3x text-muted"></i>
</div>
<input type="file" class="form-control" @change="handleFileUpload" accept="image/*">
</div>
</div>
<div v-if="imageFile">
<hr>
<h6>Resim Ayarları</h6>
<div class="row g-2">
<div class="col-6">
<label class="form-label small">Genişlik (px)</label>
<input v-model="imageOptions.width" type="number" class="form-control form-control-sm">
</div>
<div class="col-6">
<label class="form-label small">Yükseklik (px)</label>
<input v-model="imageOptions.height" type="number" class="form-control form-control-sm" placeholder="Opsiyonel">
</div>
<div class="col-6">
<label class="form-label small">Kalite (1-100)</label>
<input v-model="imageOptions.quality" type="number" class="form-control form-control-sm" min="1" max="100">
</div>
<div class="col-6">
<label class="form-label small">Format</label>
<select v-model="imageOptions.format" class="form-select form-select-sm">
<option value="jpeg">JPEG</option>
<option value="png">PNG</option>
<option value="webp">WebP</option>
<option value="avif">AVIF</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Publish Button -->
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
{{ loading ? 'İşleniyor...' : 'Yayınla' }}
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Category } from '~~/types/category';
import type { Tag, TagResponse } from '~~/types/tag';
import Swal from 'sweetalert2';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
const config = useRuntimeConfig();
const { data: authData } = useAuth();
const router = useRouter();
const loading = ref(false);
const categories = ref<Category[]>([]);
const availableTags = ref<Tag[]>([]);
const selectedTags = ref<number[]>([]);
const imageFile = ref<File | null>(null);
const imagePreview = ref<string | null>(null);
const formData = ref({
title: '',
content: '',
category_ids: [] as number[],
});
const imageOptions = ref({
width: null as number | null,
height: null as number | null,
quality: 80,
format: 'jpeg'
});
// Fetch Categories and Tags
const fetchData = async () => {
try {
const [catRes, tagRes] = await Promise.all([
$fetch<Category[]>('/api/v1/categories', {
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
}),
$fetch<TagResponse>('/api/v1/admin/tags', { // Fetching all tags (admin endpoint for safety/completeness)
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
params: { limit: 100 } // Get a reasonable amount of tags
})
]);
categories.value = catRes || [];
availableTags.value = tagRes?.data || [];
} catch (e) {
console.error(e);
}
};
const handleFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
const file = target.files[0];
imageFile.value = file;
imagePreview.value = URL.createObjectURL(file);
// Default format based on uploaded file or keep jpeg
if (file.type === 'image/png') imageOptions.value.format = 'png';
else if (file.type === 'image/webp') imageOptions.value.format = 'webp';
else imageOptions.value.format = 'jpeg';
}
};
const removeImage = () => {
imageFile.value = null;
imagePreview.value = null;
};
const savePost = async () => {
loading.value = true;
try {
const data = new FormData();
data.append('title', formData.value.title);
data.append('content', formData.value.content);
if (formData.value.category_ids.length > 0) {
data.append('category_ids', formData.value.category_ids.join(','));
}
if (selectedTags.value.length > 0) {
data.append('tag_ids', selectedTags.value.join(','));
}
// Optimize Image and append to FormData
if (imageFile.value) {
const optimizeData = new FormData();
optimizeData.append('file', imageFile.value);
if (imageOptions.value.width) optimizeData.append('width', imageOptions.value.width.toString());
if (imageOptions.value.height) optimizeData.append('height', imageOptions.value.height.toString());
optimizeData.append('quality', imageOptions.value.quality.toString());
optimizeData.append('format', imageOptions.value.format);
const optimizedBlob = await $fetch<Blob>('/api/optimize', {
method: 'POST',
body: optimizeData,
responseType: 'blob'
});
const ext = imageOptions.value.format === 'jpg' ? 'jpeg' : imageOptions.value.format;
const filename = `img-${Date.now()}.${ext === 'jpeg' ? 'jpg' : ext}`;
const optimizedFile = new File([optimizedBlob], filename, { type: `image/${ext}` });
data.append('images', optimizedFile);
}
await $fetch('/api/v1/posts', {
method: 'POST',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
body: data
});
Swal.fire('Başarılı', 'Gönderi başarıyla oluşturuldu.', 'success');
router.push('/admin/blog/posts');
} catch (error: any) {
console.error(error);
Swal.fire('Hata', error.data?.message || 'Bir hata oluştu.', 'error');
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchData();
});
</script>

View File

@@ -0,0 +1,252 @@
<template>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Gönderi Yönetimi</h2>
<NuxtLink to="/admin/blog/posts/create" class="btn btn-primary">
<i class="fas fa-plus me-2"></i> Yeni Gönderi
</NuxtLink>
</div>
<!-- Filter / Search -->
<div class="card mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<select v-model="filterStatus" class="form-select" @change="fetchPosts(1)">
<option value="">Tümü</option>
<option value="only">Silinenler (Çöp Kutusu)</option>
<option value="with">Tümü (Silinenler Dahil)</option>
</select>
</div>
<!-- Future: Add search input here -->
</div>
</div>
</div>
<!-- Posts List -->
<div class="card mb-4">
<div class="card-body">
<div v-if="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Yükleniyor...</span>
</div>
</div>
<div v-else-if="posts.length === 0" class="text-center py-5 text-muted">
Kayıt bulunamadı.
</div>
<div v-else class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>ID</th>
<th>Resim</th>
<th>Başlık</th>
<th>Kategoriler</th>
<th>Etiketler</th>
<th>Durum</th>
<th>Tarih</th>
<th class="text-end">İşlemler</th>
</tr>
</thead>
<tbody>
<tr v-for="post in posts" :key="post.ID">
<td>{{ post.ID }}</td>
<td>
<img v-if="getFirstImage(post.images)" :src="getImageUrl(getFirstImage(post.images)!)"
alt="thumbnail" class="rounded" style="width: 50px; height: 50px; object-fit: cover;">
<div v-else class="bg-light rounded d-flex align-items-center justify-content-center text-muted"
style="width: 50px; height: 50px;">
<i class="fas fa-image"></i>
</div>
</td>
<td>
<div class="fw-bold">{{ post.title }}</div>
<small class="text-muted">{{ post.slug }}</small>
</td>
<td>
<span v-for="cat in post.categories" :key="cat.ID" class="badge bg-info text-dark me-1">
{{ cat.title }}
</span>
</td>
<td>
<span v-for="tag in post.tags" :key="tag.ID" class="badge bg-secondary me-1">
{{ tag.name }}
</span>
</td>
<td>
<span v-if="post.DeletedAt" class="badge bg-danger">Silindi</span>
<span v-else class="badge bg-success">Aktif</span>
</td>
<td>
<div>{{ formatDate(post.CreatedAt) }}</div>
</td>
<td class="text-end">
<div v-if="!post.DeletedAt">
<NuxtLink :to="`/admin/blog/posts/${post.ID}`" class="btn btn-sm btn-outline-primary me-2">
<i class="fas fa-edit"></i>
</NuxtLink>
<button class="btn btn-sm btn-outline-danger" @click="deletePost(post.ID)">
<i class="fas fa-trash"></i>
</button>
</div>
<div v-else>
<button class="btn btn-sm btn-outline-success" @click="restorePost(post.ID)">
<i class="fas fa-trash-restore"></i> Geri Yükle
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<nav v-if="pagination.total > pagination.per_page" class="mt-4">
<ul class="pagination justify-content-center">
<li class="page-item" :class="{ disabled: pagination.page === 1 }">
<button class="page-link" @click="fetchPosts(pagination.page - 1)">Önceki</button>
</li>
<li v-for="page in Math.ceil(pagination.total / pagination.per_page)" :key="page"
class="page-item" :class="{ active: pagination.page === page }">
<button class="page-link" @click="fetchPosts(page)">{{ page }}</button>
</li>
<li class="page-item" :class="{ disabled: pagination.page >= Math.ceil(pagination.total / pagination.per_page) }">
<button class="page-link" @click="fetchPosts(pagination.page + 1)">Sonraki</button>
</li>
</ul>
</nav>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Post, PostResponse } from '~~/types/post';
import Swal from 'sweetalert2';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
const config = useRuntimeConfig();
const { data: authData } = useAuth();
const posts = ref<Post[]>([]);
const loading = ref(false);
const filterStatus = ref(''); // '' = active (default from API perspective usually?), or we need to handle default
// Default listing in doc: /api/v1/admin/posts
// Trashed only: ?trashed=only
// With trashed: ?trashed=with
const pagination = ref({
page: 1,
per_page: 10,
total: 0
});
const fetchPosts = async (page = 1) => {
loading.value = true;
try {
const query: any = { page, limit: pagination.value.per_page };
if (filterStatus.value) {
query.trashed = filterStatus.value;
}
const res = await $fetch<PostResponse>('/api/v1/admin/posts', {
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
query
});
posts.value = res.data || [];
pagination.value = res.meta;
} catch (e) {
console.error(e);
posts.value = [];
} finally {
loading.value = false;
}
};
const deletePost = async (id: number) => {
const result = await Swal.fire({
title: 'Emin misiniz?',
text: "Bu gönderi silinecek (Çöp kutusuna taşınacak).",
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Evet, Sil',
cancelButtonText: 'İptal',
confirmButtonColor: '#d33'
});
if (!result.isConfirmed) return;
try {
await $fetch(`/api/v1/posts/${id}`, {
method: 'DELETE',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Silindi', 'Gönderi silindi.', 'success');
fetchPosts(pagination.value.page);
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Silme işlemi başarısız.', 'error');
}
};
const restorePost = async (id: number) => {
// Implement restore logic if API supports it, otherwise basic delete is usually soft delete.
// If current delete IS soft delete, usually there's a restore endpoint or update with deleted_at=null.
// Assuming for now we just show it for concept, but strict restore endpoint might be needed.
// For now, let's keep it simple. If no explicit restore endpoint documented, maybe we can't restore easily from UI yet.
// But wait, the user asked to implement based on admin_post.md.
// Use case "sadece soft delete olmuslar listesi" implies we can see them.
// Usually a PUT to /api/v1/admin/posts/{id}/restore or similar.
// Since not documented, I will skip implementation of ACTUAL restore call but keep the button or maybe comment it out
// until confirmed. Or I can assume standard behaviour if I knew the backend.
// Let's assume standard soft delete REST pattern often allows toggle or specific endpoint.
// I'll leave the button but functionality might be missing.
// Actually, let's look at the docs again.
// Docs only show GET lists.
// I will remove Restore button functionality for now to avoid errors, or just show alert 'Not implemented'.
Swal.fire('Bilgi', 'Geri yükleme özelliği henüz API tarafında dökümante edilmedi.', 'info');
};
const getFirstImage = (imagesStr: string): string | null => {
try {
// API returns "images": "uploads/posts/..." OR JSON string "[\"...\"]"
// Let's try to parse if it looks like JSON
if (imagesStr.startsWith('[') || imagesStr.startsWith('{')) {
const parsed = JSON.parse(imagesStr);
if (Array.isArray(parsed) && parsed.length > 0) return parsed[0];
}
return imagesStr;
} catch (e) {
return imagesStr;
}
};
const getImageUrl = (path: string) => {
if (path.startsWith('http')) return path;
if (path.startsWith('/')) return `${config.public.BASE_API_URL}${path}`;
return `${config.public.BASE_API_URL}/${path}`;
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('tr-TR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
onMounted(() => {
fetchPosts();
});
</script>

View File

@@ -0,0 +1,317 @@
<template>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Etiket Yönetimi</h2>
<button class="btn btn-primary" @click="openModal()">
<i class="fas fa-plus me-2"></i> Yeni Etiket
</button>
</div>
<div class="card mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-secondary" :class="{ active: filterTrashed === 'without' }" @click="changeFilter('without')">
Aktif
</button>
<button type="button" class="btn btn-outline-secondary" :class="{ active: filterTrashed === 'only' }" @click="changeFilter('only')">
Çöp Kutusu
</button>
<button type="button" class="btn btn-outline-secondary" :class="{ active: filterTrashed === 'with' }" @click="changeFilter('with')">
Hepsi
</button>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div v-if="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Yükleniyor...</span>
</div>
</div>
<div v-else class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>ID</th>
<th>Etiket Adı</th>
<th>Oluşturulma</th>
<th>Durum</th>
<th class="text-end">İşlemler</th>
</tr>
</thead>
<tbody>
<tr v-for="tag in tags" :key="tag.ID">
<td>#{{ tag.ID }}</td>
<td>
<span class="fw-bold">{{ tag.name }}</span>
</td>
<td>{{ new Date(tag.CreatedAt).toLocaleDateString('tr-TR') }}</td>
<td>
<span v-if="tag.DeletedAt" class="badge bg-danger">Silinmiş</span>
<span v-else class="badge bg-success">Aktif</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button v-if="!tag.DeletedAt" class="btn btn-outline-primary" @click="openModal(tag)" title="Düzenle">
<i class="fas fa-edit"></i>
</button>
<button v-if="!tag.DeletedAt" class="btn btn-outline-danger" @click="deleteTag(tag.ID)" title="Sil">
<i class="fas fa-trash"></i>
</button>
<button v-if="tag.DeletedAt" class="btn btn-outline-success" @click="restoreTag(tag.ID)" title="Geri Yükle">
<i class="fas fa-trash-restore"></i>
</button>
<button v-if="tag.DeletedAt" class="btn btn-outline-dark" @click="hardDeleteTag(tag.ID)" title="Kalıcı Sil">
<i class="fas fa-times"></i>
</button>
</div>
</td>
</tr>
<tr v-if="tags.length === 0">
<td colspan="5" class="text-center py-4">Kayıt bulunamadı.</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<nav v-if="totalPages > 1" class="mt-4">
<ul class="pagination justify-content-center">
<li class="page-item" :class="{ disabled: currentPage === 1 }">
<button class="page-link" @click="changePage(currentPage - 1)">Önceki</button>
</li>
<li v-for="page in totalPages" :key="page" class="page-item" :class="{ active: currentPage === page }">
<button class="page-link" @click="changePage(page)">{{ page }}</button>
</li>
<li class="page-item" :class="{ disabled: currentPage === totalPages }">
<button class="page-link" @click="changePage(currentPage + 1)">Sonraki</button>
</li>
</ul>
</nav>
</div>
</div>
<!-- Tag Modal -->
<div class="modal fade" id="tagModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ isEditing ? 'Etiket Düzenle' : 'Yeni Etiket Ekle' }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form @submit.prevent="saveTag">
<div class="mb-3">
<label class="form-label">Etiket Adı</label>
<input v-model="tagForm.name" type="text" class="form-control" required>
</div>
<div class="text-end">
<button type="button" class="btn btn-secondary me-2" data-bs-dismiss="modal">İptal</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
<span v-if="saving" class="spinner-border spinner-border-sm me-2"></span>
Kaydet
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Tag, TagResponse } from '~~/types/tag';
import Swal from 'sweetalert2';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
const config = useRuntimeConfig();
const { data: authData } = useAuth();
const loading = ref(false);
const saving = ref(false);
const tags = ref<Tag[]>([]);
const currentPage = ref(1);
const totalPages = ref(1);
const filterTrashed = ref<'without' | 'with' | 'only'>('without');
const tagForm = ref({
ID: 0,
name: ''
});
const isEditing = ref(false);
let tagModal: any = null;
const fetchTags = async () => {
loading.value = true;
try {
const res = await $fetch<TagResponse>('/api/v1/admin/tags', {
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
params: {
page: currentPage.value,
limit: 10,
...(filterTrashed.value !== 'without' && { trashed: filterTrashed.value })
}
});
if (res && res.data) {
tags.value = res.data;
totalPages.value = Math.ceil(res.meta.total / res.meta.per_page);
} else {
tags.value = [];
}
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Etiketler yüklenirken bir hata oluştu.', 'error');
} finally {
loading.value = false;
}
};
const changePage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page;
fetchTags();
}
};
const changeFilter = (filter: 'without' | 'with' | 'only') => {
filterTrashed.value = filter;
currentPage.value = 1;
fetchTags();
};
const openModal = (tag: Tag | null = null) => {
if (tag) {
isEditing.value = true;
tagForm.value = { ID: tag.ID, name: tag.name };
} else {
isEditing.value = false;
tagForm.value = { ID: 0, name: '' };
}
const modalEl = document.getElementById('tagModal');
if (modalEl) {
// @ts-ignore
tagModal = new bootstrap.Modal(modalEl);
tagModal.show();
}
};
const saveTag = async () => {
saving.value = true;
try {
let url = '/api/v1/tags';
let method: 'POST' | 'PUT' = 'POST';
if (isEditing.value && tagForm.value.ID) {
url = `/api/v1/tags/${tagForm.value.ID}`;
method = 'PUT';
}
await $fetch(url, {
method: method,
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
body: { name: tagForm.value.name }
});
if (tagModal) tagModal.hide();
Swal.fire('Başarılı', `Etiket ${isEditing.value ? 'güncellendi' : 'eklendi'}.`, 'success');
fetchTags();
} catch (error: any) {
console.error(error);
Swal.fire('Hata', error.data?.message || 'İşlem başarısız.', 'error');
} finally {
saving.value = false;
}
};
const deleteTag = async (id: number) => {
const result = await Swal.fire({
title: 'Emin misiniz?',
text: "Bu etiketi çöp kutusuna taşımak istediğinize emin misiniz?",
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Evet, Sil',
cancelButtonText: 'İptal'
});
if (result.isConfirmed) {
try {
await $fetch(`/api/v1/tags/${id}`, {
method: 'DELETE',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Silindi', 'Etiket çöp kutusuna taşındı.', 'success');
fetchTags();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Silme işlemi başarısız.', 'error');
}
}
};
const hardDeleteTag = async (id: number) => {
const result = await Swal.fire({
title: 'DİKKAT! Kalıcı Silme',
text: "Bu işlem geri alınamaz! Etiketi tamamen silmek istediğinize emin misiniz?",
icon: 'error',
showCancelButton: true,
confirmButtonText: 'Evet, Tamamen Sil',
cancelButtonText: 'İptal'
});
if (result.isConfirmed) {
try {
await $fetch(`/api/v1/admin/tags/${id}/hard`, {
method: 'DELETE',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Silindi', 'Etiket kalıcı olarak silindi.', 'success');
fetchTags();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Kalıcı silme işlemi başarısız.', 'error');
}
}
};
const restoreTag = async (id: number) => {
try {
await $fetch(`/api/v1/admin/tags/${id}/restore`, {
method: 'POST',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Geri Yüklendi', 'Etiket başarıyla geri yüklendi.', 'success');
fetchTags();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Geri yükleme işlemi başarısız.', 'error');
}
};
onMounted(() => {
fetchTags();
});
</script>

14
app/pages/admin/index.vue Normal file
View File

@@ -0,0 +1,14 @@
<template>
<div>
admin index
</div>
</template>
<script lang="ts" setup>
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
</script>
<style></style>

View File

@@ -0,0 +1,501 @@
<template>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Güvenlik & Yönetimi</h2>
</div>
<ul class="nav nav-tabs mb-4" id="securityTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="whitelist-tab" data-bs-toggle="tab" data-bs-target="#whitelist"
type="button" role="tab" aria-controls="whitelist" aria-selected="true">CORS Whitelist</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="blacklist-tab" data-bs-toggle="tab" data-bs-target="#blacklist"
type="button" role="tab" aria-controls="blacklist" aria-selected="false">CORS Blacklist</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="ratelimit-tab" data-bs-toggle="tab" data-bs-target="#ratelimit"
type="button" role="tab" aria-controls="ratelimit" aria-selected="false">Rate Limit</button>
</li>
</ul>
<div class="tab-content" id="securityTabContent">
<!-- WHITELIST TAB -->
<div class="tab-pane fade show active" id="whitelist" role="tabpanel" aria-labelledby="whitelist-tab">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center bg-white">
<h5 class="mb-0">Whitelist Origins</h5>
<div class="d-flex align-items-center">
<div class="form-check form-switch me-3 mb-0">
<input class="form-check-input" type="checkbox" v-model="showDeleted"
id="whitelistDeleted">
<label class="form-check-label" for="whitelistDeleted">Silinenler</label>
</div>
<button class="btn btn-success btn-sm" @click="openModal('whitelist')"><i
class="fas fa-plus me-2"></i> Yeni
Ekle</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Origin</th>
<th>ıklama</th>
<th>Durum</th>
<th class="text-end" width="150">İşlemler</th>
</tr>
</thead>
<tbody>
<tr v-for="item in whitelistItems" :key="item.ID">
<td>{{ item.origin }}</td>
<td>{{ item.description }}</td>
<td>
<span class="badge" :class="item.is_active ? 'bg-success' : 'bg-secondary'">
{{ item.is_active ? 'Aktif' : 'Pasif' }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<template v-if="!item.DeletedAt">
<button class="btn btn-outline-primary"
@click="openModal('whitelist', item)" title="Düzenle">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-outline-danger"
@click="deleteItem('whitelist', item.ID)" title="Sil">
<i class="fas fa-trash"></i>
</button>
</template>
<button v-else class="btn btn-outline-success"
@click="restoreItem('whitelist', item.ID)" title="Geri Yükle">
<i class="fas fa-trash-restore"></i>
</button>
</div>
</td>
</tr>
<tr v-if="whitelistItems.length === 0">
<td colspan="4" class="text-center py-4">Kayıt bulunamadı.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- BLACKLIST TAB -->
<div class="tab-pane fade" id="blacklist" role="tabpanel" aria-labelledby="blacklist-tab">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center bg-white">
<h5 class="mb-0">Blacklist Origins</h5>
<div class="d-flex align-items-center">
<div class="form-check form-switch me-3 mb-0">
<input class="form-check-input" type="checkbox" v-model="showDeleted"
id="blacklistDeleted">
<label class="form-check-label" for="blacklistDeleted">Silinenler</label>
</div>
<button class="btn btn-success btn-sm" @click="openModal('blacklist')"><i
class="fas fa-plus me-2"></i>
Yeni
Ekle</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Origin</th>
<th>Sebep</th>
<th>Durum</th>
<th class="text-end" width="150">İşlemler</th>
</tr>
</thead>
<tbody>
<tr v-for="item in blacklistItems" :key="item.ID">
<td>{{ item.origin }}</td>
<td>{{ item.reason }}</td>
<td>
<span class="badge" :class="item.is_active ? 'bg-danger' : 'bg-secondary'">
{{ item.is_active ? 'Aktif' : 'Pasif' }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<template v-if="!item.DeletedAt">
<button class="btn btn-outline-primary"
@click="openModal('blacklist', item)" title="Düzenle">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-outline-danger"
@click="deleteItem('blacklist', item.ID)" title="Sil">
<i class="fas fa-trash"></i>
</button>
</template>
<button v-else class="btn btn-outline-success"
@click="restoreItem('blacklist', item.ID)" title="Geri Yükle">
<i class="fas fa-trash-restore"></i>
</button>
</div>
</td>
</tr>
<tr v-if="blacklistItems.length === 0">
<td colspan="4" class="text-center py-4">Kayıt bulunamadı.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- RATE LIMIT TAB -->
<div class="tab-pane fade" id="ratelimit" role="tabpanel" aria-labelledby="ratelimit-tab">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center bg-white">
<h5 class="mb-0">Rate Limits</h5>
<div class="d-flex align-items-center">
<div class="form-check form-switch me-3 mb-0">
<input class="form-check-input" type="checkbox" v-model="showDeleted"
id="ratelimitDeleted">
<label class="form-check-label" for="ratelimitDeleted">Silinenler</label>
</div>
<button class="btn btn-success btn-sm" @click="openModal('ratelimit')"><i
class="fas fa-plus me-2"></i>
Yeni
Ekle</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>İsim</th>
<th>Max İstek / Süre (sn)</th>
<th>ıklama</th>
<th>Durum</th>
<th class="text-end" width="150">İşlemler</th>
</tr>
</thead>
<tbody>
<tr v-for="item in rateLimitItems" :key="item.ID">
<td>{{ item.name }}</td>
<td>{{ item.max_requests }} / {{ item.window_seconds }}sn</td>
<td>{{ item.description }}</td>
<td>
<span class="badge" :class="item.is_active ? 'bg-success' : 'bg-secondary'">
{{ item.is_active ? 'Aktif' : 'Pasif' }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<template v-if="!item.DeletedAt">
<button class="btn btn-outline-primary"
@click="openModal('ratelimit', item)" title="Düzenle">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-outline-danger"
@click="deleteItem('ratelimit', item.ID)" title="Sil">
<i class="fas fa-trash"></i>
</button>
</template>
<button v-else class="btn btn-outline-success"
@click="restoreItem('ratelimit', item.ID)" title="Geri Yükle">
<i class="fas fa-trash-restore"></i>
</button>
</div>
</td>
</tr>
<tr v-if="rateLimitItems.length === 0">
<td colspan="5" class="text-center py-4">Kayıt bulunamadı.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- MODAL -->
<div class="modal fade" id="securityModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ modalTitle }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form @submit.prevent="saveItem">
<!-- Common Fields -->
<div v-if="activeTab === 'whitelist' || activeTab === 'blacklist'" class="mb-3">
<label class="form-label">Origin (URL)</label>
<input v-model="formData.origin" type="text" class="form-control"
placeholder="http://example.com" required>
</div>
<div v-if="activeTab === 'whitelist'" class="mb-3">
<label class="form-label">ıklama</label>
<input v-model="formData.description" type="text" class="form-control">
</div>
<div v-if="activeTab === 'blacklist'" class="mb-3">
<label class="form-label">Sebep</label>
<input v-model="formData.reason" type="text" class="form-control">
</div>
<!-- Rate Limit Fields -->
<div v-if="activeTab === 'ratelimit'" class="mb-3">
<label class="form-label">İsim (Endpoint/Key)</label>
<input v-model="formData.name" type="text" class="form-control" required>
</div>
<div v-if="activeTab === 'ratelimit'" class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Max İstek</label>
<input v-model="formData.max_requests" type="number" class="form-control" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Süre (Saniye)</label>
<input v-model="formData.window_seconds" type="number" class="form-control"
required>
</div>
</div>
<div v-if="activeTab === 'ratelimit'" class="mb-3">
<label class="form-label">ıklama</label>
<input v-model="formData.description" type="text" class="form-control">
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" v-model="formData.is_active"
id="isActiveCheck">
<label class="form-check-label" for="isActiveCheck">Aktif</label>
</div>
</div>
<div class="text-end">
<button type="button" class="btn btn-secondary me-2"
data-bs-dismiss="modal">İptal</button>
<button type="submit" class="btn btn-primary" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
Kaydet
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { CorsWhitelistItem, CorsBlacklistItem, RateLimitItem } from '~~/types/security';
import Swal from 'sweetalert2';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
const config = useRuntimeConfig();
const { data: authData } = useAuth();
const loading = ref(false);
const activeTab = ref<'whitelist' | 'blacklist' | 'ratelimit'>('whitelist');
const showDeleted = ref(false); // Global toggle for simplicity, or could be per tab
const whitelistItems = ref<CorsWhitelistItem[]>([]);
const blacklistItems = ref<CorsBlacklistItem[]>([]);
const rateLimitItems = ref<RateLimitItem[]>([]);
const formData = ref<any>({});
const isEditing = ref(false);
let securityModal: any = null;
const modalTitle = computed(() => {
const action = isEditing.value ? 'Düzenle' : 'Ekle';
if (activeTab.value === 'whitelist') return `Whitelist ${action}`;
if (activeTab.value === 'blacklist') return `Blacklist ${action}`;
return `Rate Limit ${action}`;
});
// Watch for tab or filter changes
watch([activeTab, showDeleted], () => {
fetchItems();
});
// -- API HANDLERS --
const fetchItems = async () => {
try {
const query = showDeleted.value ? { deleted: 'true' } : {};
const headers = { Authorization: `Bearer ${(authData.value as any)?.accessToken}` };
if (activeTab.value === 'whitelist') {
const res = await $fetch<any>('/api/v1/admin/cors/whitelist', { baseURL: config.public.BASE_API_URL, headers, query });
whitelistItems.value = res.items || [];
} else if (activeTab.value === 'blacklist') {
const res = await $fetch<any>('/api/v1/admin/cors/blacklist', { baseURL: config.public.BASE_API_URL, headers, query });
blacklistItems.value = res.items || [];
} else {
const res = await $fetch<any>('/api/v1/admin/rate-limit', { baseURL: config.public.BASE_API_URL, headers, query });
rateLimitItems.value = res.items || [];
}
} catch (error) {
console.error('Fetch error:', error);
// Fallback or empty list on error
if (activeTab.value === 'whitelist') whitelistItems.value = [];
else if (activeTab.value === 'blacklist') blacklistItems.value = [];
else rateLimitItems.value = [];
}
};
const openModal = (tab: 'whitelist' | 'blacklist' | 'ratelimit', item: any = null) => {
activeTab.value = tab;
if (item) {
isEditing.value = true;
formData.value = { ...item };
} else {
isEditing.value = false;
formData.value = { is_active: true };
}
const modalEl = document.getElementById('securityModal');
if (modalEl) {
// @ts-ignore
securityModal = new bootstrap.Modal(modalEl);
securityModal.show();
}
};
const saveItem = async () => {
loading.value = true;
try {
let endpoint = '';
if (activeTab.value === 'whitelist') endpoint = '/api/v1/admin/cors/whitelist';
else if (activeTab.value === 'blacklist') endpoint = '/api/v1/admin/cors/blacklist';
else endpoint = '/api/v1/admin/rate-limit';
let method: 'POST' | 'PUT' = 'POST';
if (isEditing.value && formData.value.ID) {
endpoint += `/${formData.value.ID}`;
method = 'PUT';
}
// Convert numbers for RateLimit
if (activeTab.value === 'ratelimit') {
formData.value.max_requests = Number(formData.value.max_requests);
formData.value.window_seconds = Number(formData.value.window_seconds);
}
await $fetch(endpoint, {
method,
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
body: formData.value
});
if (securityModal) securityModal.hide();
Swal.fire('Başarılı', 'İşlem başarıyla tamamlandı.', 'success');
fetchItems();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'İşlem başarısız.', 'error');
} finally {
loading.value = false;
}
};
const deleteItem = async (tab: string, id: number) => {
const result = await Swal.fire({
title: 'Silme Tipi?',
text: "Kayıt nasıl silinsin?",
icon: 'warning',
showCancelButton: true,
showDenyButton: true,
confirmButtonText: 'Soft Sil (Arşivle)',
denyButtonText: 'Hard Sil (Kalıcı)',
cancelButtonText: 'İptal'
});
if (result.isDismissed) return;
const isHard = result.isDenied;
try {
let endpoint = '';
if (tab === 'whitelist') endpoint = `/api/v1/admin/cors/whitelist/${id}`;
else if (tab === 'blacklist') endpoint = `/api/v1/admin/cors/blacklist/${id}`;
else endpoint = `/api/v1/admin/rate-limit/${id}`;
if (isHard) endpoint += '/hard';
await $fetch(endpoint, {
method: 'DELETE',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Silindi!', isHard ? 'Kayıt kalıcı olarak silindi.' : 'Kayıt arşivlendi (soft delete).', 'success');
fetchItems();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Silme işlemi başarısız.', 'error');
}
};
const restoreItem = async (tab: string, id: number) => {
const result = await Swal.fire({
title: 'Emin misiniz?',
text: "Kayıt geri yüklenecek.",
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Geri Yükle',
cancelButtonText: 'İptal'
});
if (!result.isConfirmed) return;
try {
let endpoint = '';
if (tab === 'whitelist') endpoint = `/api/v1/admin/cors/whitelist/${id}/restore`;
else if (tab === 'blacklist') endpoint = `/api/v1/admin/cors/blacklist/${id}/restore`;
else endpoint = `/api/v1/admin/rate-limit/${id}/restore`;
await $fetch(endpoint, {
method: 'POST', // Assuming POST for restore action
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Başarılı', 'Kayıt geri yüklendi.', 'success');
fetchItems();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Geri yükleme başarısız. Endpoint mevcut olmayabilir.', 'error');
}
};
onMounted(() => {
// Bootstrap tabs events
const tabEls = document.querySelectorAll('button[data-bs-toggle="tab"]');
tabEls.forEach(tabEl => {
tabEl.addEventListener('shown.bs.tab', (event: any) => {
const targetId = event.target.getAttribute('data-bs-target');
if (targetId === '#whitelist') activeTab.value = 'whitelist';
if (targetId === '#blacklist') activeTab.value = 'blacklist';
if (targetId === '#ratelimit') activeTab.value = 'ratelimit';
// fetchItems() is called by watcher
});
});
fetchItems(); // Initial fetch
});
</script>
```

View File

@@ -0,0 +1,578 @@
<template>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Ayarlar</h2>
</div>
<ul class="nav nav-tabs mb-4" id="settingsTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="general-tab" data-bs-toggle="tab" data-bs-target="#general" type="button"
role="tab" aria-controls="general" aria-selected="true">Genel Ayarlar</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="hero-tab" data-bs-toggle="tab" data-bs-target="#hero" type="button" role="tab"
aria-controls="hero" aria-selected="false">Hero (Banner) Ayarları</button>
</li>
</ul>
<div class="tab-content" id="settingsTabContent">
<!-- GENERAL SETTINGS TAB -->
<div class="tab-pane fade show active" id="general" role="tabpanel" aria-labelledby="general-tab">
<div class="card">
<div class="card-body">
<form @submit.prevent="updateSettings">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Site Başlığı</label>
<input v-model="settingForm.title" type="text" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Slogan</label>
<input v-model="settingForm.slogan" type="text" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Telefon</label>
<input v-model="settingForm.phone" type="text" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Email</label>
<input v-model="settingForm.email" type="email" class="form-control">
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Adres</label>
<textarea v-model="settingForm.address" class="form-control" rows="2"></textarea>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Harita Embed Code</label>
<textarea v-model="settingForm.map_embed" class="form-control" rows="3"></textarea>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Copyright Metni</label>
<input v-model="settingForm.copyright" type="text" class="form-control">
</div>
</div>
<h5 class="mt-4">SEO Ayarları</h5>
<hr>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Meta Başlık</label>
<input v-model="settingForm.meta_title" type="text" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Meta ıklama</label>
<input v-model="settingForm.meta_description" type="text" class="form-control">
</div>
</div>
<h5 class="mt-4">Sosyal Medya</h5>
<hr>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Facebook</label>
<input v-model="settingForm.facebook" type="text" class="form-control">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Twitter / X</label>
<input v-model="settingForm.x" type="text" class="form-control">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Instagram</label>
<input v-model="settingForm.instagram" type="text" class="form-control">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Linkedin</label>
<input v-model="settingForm.linkedin" type="text" class="form-control">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Pinterest</label>
<input v-model="settingForm.pinterest" type="text" class="form-control">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Whatsapp</label>
<input v-model="settingForm.whatsapp" type="text" class="form-control">
</div>
</div>
<h5 class="mt-4">Logolar</h5>
<hr>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Beyaz Logo (Dark Mod için)</label>
<input @change="handleFileUpload($event, 'w_logo')" type="file" class="form-control">
<div v-if="settingForm.w_logo && typeof settingForm.w_logo === 'string'" class="mt-2">
<img :src="config.public.BASE_API_URL + settingForm.w_logo" height="50" class="bg-dark p-1">
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Siyah Logo (Light Mod için)</label>
<input @change="handleFileUpload($event, 'b_logo')" type="file" class="form-control">
<div v-if="settingForm.b_logo && typeof settingForm.b_logo === 'string'" class="mt-2">
<img :src="config.public.BASE_API_URL + settingForm.b_logo" height="50" class="bg-light border p-1">
</div>
</div>
</div>
<h5 class="mt-4">Resim Optimizasyon Ayarları (Logo İçin)</h5>
<hr>
<div class="row bg-light p-3 rounded mx-1">
<div class="col-md-3 mb-2">
<label class="form-label">Format</label>
<select v-model="imageSettings.format" class="form-select">
<option value="avif">AVIF</option>
<option value="webp">WEBP</option>
<option value="png">PNG</option>
<option value="jpg">JPG</option>
</select>
</div>
<div class="col-md-3 mb-2">
<label class="form-label">Kalite (1-100)</label>
<input v-model="imageSettings.quality" type="number" min="1" max="100" class="form-control">
</div>
<div class="col-md-3 mb-2">
<label class="form-label">Genişlik (px)</label>
<input v-model="imageSettings.width" type="number" class="form-control" placeholder="Opsiyonel">
</div>
<div class="col-md-3 mb-2">
<label class="form-label">Yükseklik (px)</label>
<input v-model="imageSettings.height" type="number" class="form-control" placeholder="Opsiyonel">
</div>
</div>
<div class="mt-4 text-end">
<button type="submit" class="btn btn-primary" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
Kaydet
</button>
</div>
</form>
</div>
</div>
</div>
<!-- HERO SETTINGS TAB -->
<div class="tab-pane fade" id="hero" role="tabpanel" aria-labelledby="hero-tab">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center bg-white">
<h5 class="mb-0">Hero Listesi</h5>
<button class="btn btn-success btn-sm" @click="openHeroModal()"><i class="fas fa-plus me-2"></i> Yeni
Ekle</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Resim</th>
<th>Başlık</th>
<th>Alt Başlıklar</th>
<th>Durum</th>
<th class="text-end" width="100">İşlemler</th>
</tr>
</thead>
<tbody>
<tr v-for="hero in heroes" :key="hero.ID">
<td>
<img v-if="hero.image" :src="config.public.BASE_API_URL + hero.image"
style="max-width: 60px; height: 40px; object-fit: cover;" class="rounded border">
<span v-else>-</span>
</td>
<td>{{ hero.title }}</td>
<td>
<small class="d-block text-muted">{{ hero.text1 }}</small>
<small class="d-block text-muted">{{ hero.text2 }}</small>
</td>
<td>
<span class="badge" :class="hero.is_active ? 'bg-success' : 'bg-secondary'">
{{ hero.is_active ? 'Aktif' : 'Pasif' }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary" @click="openHeroModal(hero)" title="Düzenle">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-outline-danger" @click="deleteHero(hero.ID)" title="Sil">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
<tr v-if="heroes.length === 0">
<td colspan="5" class="text-center py-4">Kayıt bulunamadı.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- HERO MODAL -->
<div class="modal fade" id="heroModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ isEditingHero ? 'Hero Düzenle' : 'Yeni Hero Ekle' }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form @submit.prevent="saveHero">
<div class="row">
<div class="col-md-12 mb-3">
<label class="form-label">Başlık</label>
<input v-model="heroForm.title" type="text" class="form-control" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Text 1</label>
<input v-model="heroForm.text1" type="text" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Text 2</label>
<input v-model="heroForm.text2" type="text" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Text 4</label>
<input v-model="heroForm.text4" type="text" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Text 5</label>
<input v-model="heroForm.text5" type="text" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Renk</label>
<div class="input-group">
<input v-model="heroForm.color" type="color" class="form-control form-control-color"
title="Renk Seçin">
<input v-model="heroForm.color" type="text" class="form-control" placeholder="#ffffff">
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Durum</label>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" v-model="heroForm.is_active" id="heroActive">
<label class="form-check-label" for="heroActive">Aktif</label>
</div>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Görsel</label>
<input @change="handleHeroFileUpload($event)" type="file" class="form-control">
<div v-if="heroForm.image && typeof heroForm.image === 'string'" class="mt-2">
<small>Mevcut Görsel:</small>
<img :src="config.public.BASE_API_URL + heroForm.image" height="80" class="d-block mt-1 rounded">
</div>
</div>
<div class="col-md-12 mb-3">
<div class="p-3 bg-light rounded border">
<h6>Resim Optimizasyon Ayarları</h6>
<div class="row">
<div class="col-md-3 mb-2">
<label class="form-label">Format</label>
<select v-model="imageSettings.format" class="form-select">
<option value="avif">AVIF</option>
<option value="webp">WEBP</option>
<option value="png">PNG</option>
<option value="jpg">JPG</option>
</select>
</div>
<div class="col-md-3 mb-2">
<label class="form-label">Kalite</label>
<input v-model="imageSettings.quality" type="number" min="1" max="100" class="form-control">
</div>
<div class="col-md-3 mb-2">
<label class="form-label">Genişlik</label>
<input v-model="imageSettings.width" type="number" class="form-control" placeholder="Opsiyonel">
</div>
<div class="col-md-3 mb-2">
<label class="form-label">Yükseklik</label>
<input v-model="imageSettings.height" type="number" class="form-control"
placeholder="Opsiyonel">
</div>
</div>
</div>
</div>
</div>
<div class="text-end">
<button type="button" class="btn btn-secondary me-2" data-bs-dismiss="modal">İptal</button>
<button type="submit" class="btn btn-primary" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
Kaydet
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Setting } from '~~/types/setting';
import type { Hero } from '~~/types/hero';
import Swal from 'sweetalert2';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
const config = useRuntimeConfig();
const { data: authData } = useAuth();
const loading = ref(false);
// --- SETTINGS ---
const settingForm = ref<Partial<Setting>>({});
const w_logo_file = ref<File | null>(null);
const b_logo_file = ref<File | null>(null);
const imageSettings = ref({
format: 'avif',
quality: 80,
width: null as number | null,
height: null as number | null
});
// Fetch Settings
const fetchSettings = async () => {
try {
const data = await $fetch<Setting>('/api/v1/setting', {
baseURL: config.public.BASE_API_URL,
});
if (data) {
settingForm.value = { ...data };
}
} catch (error) {
console.error('Settings fetch error:', error);
}
};
const handleFileUpload = (event: Event, type: 'w_logo' | 'b_logo') => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
if (type === 'w_logo') w_logo_file.value = target.files[0];
if (type === 'b_logo') b_logo_file.value = target.files[0];
}
};
const optimizeImage = async (file: File): Promise<File> => {
const formData = new FormData();
formData.append('file', file);
formData.append('format', imageSettings.value.format);
formData.append('quality', String(imageSettings.value.quality));
if (imageSettings.value.width) formData.append('width', String(imageSettings.value.width));
if (imageSettings.value.height) formData.append('height', String(imageSettings.value.height));
const optimizedBlob = await $fetch<Blob>('/api/optimize', {
method: 'POST',
body: formData,
responseType: 'blob'
});
const ext = imageSettings.value.format === 'jpg' ? 'jpeg' : imageSettings.value.format;
const filename = `img-${Date.now()}-${Math.floor(Math.random() * 10000)}.${ext === 'jpeg' ? 'jpg' : ext}`;
const optimizedFile = new File([optimizedBlob], filename, {
type: `image/${ext}`
});
return optimizedFile;
};
const updateSettings = async () => {
loading.value = true;
try {
const formData = new FormData();
Object.keys(settingForm.value).forEach(key => {
const value = (settingForm.value as any)[key];
if (value !== null && value !== undefined && key !== 'w_logo' && key !== 'b_logo' && key !== 'ID' && key !== 'CreatedAt' && key !== 'UpdatedAt' && key !== 'DeletedAt') {
formData.append(key, String(value));
}
});
if (w_logo_file.value) {
const optimized = await optimizeImage(w_logo_file.value);
formData.append('w_logo', optimized);
}
if (b_logo_file.value) {
const optimized = await optimizeImage(b_logo_file.value);
formData.append('b_logo', optimized);
}
formData.append('is_active', 'true');
let url = '/api/v1/setting';
let method: 'POST' | 'PUT' = 'POST';
if (settingForm.value.ID) {
url = `/api/v1/setting/${settingForm.value.ID}`;
method = 'PUT';
}
await $fetch(url, {
method: method,
baseURL: config.public.BASE_API_URL,
body: formData,
headers: {
Authorization: `Bearer ${(authData.value as any)?.accessToken}`
}
});
Swal.fire('Başarılı', 'Ayarlar güncellendi.', 'success');
fetchSettings(); // Refresh
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Güncelleme sırasında hata oluştu.', 'error');
} finally {
loading.value = false;
}
};
// --- HERO ---
const heroes = ref<Hero[]>([]);
const heroForm = ref<Partial<Hero>>({});
const heroImageFile = ref<File | null>(null);
const isEditingHero = ref(false);
let heroModal: any = null;
const fetchHeroes = async () => {
try {
const response = await $fetch<any>('/api/v1/heroes', {
baseURL: config.public.BASE_API_URL,
});
if (response && Array.isArray(response.data)) {
heroes.value = response.data;
} else if (Array.isArray(response)) {
heroes.value = response;
} else if (response && typeof response === 'object') {
heroes.value = [response]; // Wrap single object
} else {
heroes.value = [];
}
} catch (error) {
console.error('Hero fetch error:', error);
}
};
const openHeroModal = (hero: Hero | null = null) => {
if (hero) {
isEditingHero.value = true;
heroForm.value = { ...hero };
} else {
isEditingHero.value = false;
heroForm.value = { is_active: true };
}
heroImageFile.value = null;
// Initialize Modal
const modalEl = document.getElementById('heroModal');
if (modalEl) {
// @ts-ignore
heroModal = new bootstrap.Modal(modalEl);
heroModal.show();
}
};
const handleHeroFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
heroImageFile.value = target.files[0];
}
};
const saveHero = async () => {
loading.value = true;
try {
const formData = new FormData();
Object.keys(heroForm.value).forEach(key => {
const value = (heroForm.value as any)[key];
if (value !== null && value !== undefined && key !== 'image' && key !== 'ID' && key !== 'CreatedAt' && key !== 'UpdatedAt' && key !== 'DeletedAt') {
formData.append(key, String(value));
}
});
if (heroImageFile.value) {
const optimized = await optimizeImage(heroImageFile.value);
formData.append('image', optimized);
}
// Ensure is_active sent as string 'true'/'false' if backend expects it
// formData.set('is_active', String(heroForm.value.is_active));
let url = '/api/v1/hero';
let method: 'POST' | 'PUT' = 'POST';
if (isEditingHero.value && heroForm.value.ID) {
url = `/api/v1/hero/${heroForm.value.ID}`;
method = 'PUT';
}
await $fetch(url, {
method: method,
baseURL: config.public.BASE_API_URL,
body: formData,
headers: {
Authorization: `Bearer ${(authData.value as any)?.accessToken}`
}
});
if (heroModal) heroModal.hide();
Swal.fire('Başarılı', `Hero ${isEditingHero.value ? 'güncellendi' : 'eklendi'}.`, 'success');
fetchHeroes();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'İşlem başarısız.', 'error');
} finally {
loading.value = false;
}
};
const deleteHero = async (id: number) => {
const result = await Swal.fire({
title: 'Emin misiniz?',
text: "Bu hero kaydını silmek istediğinize emin misiniz?",
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Evet, Sil',
cancelButtonText: 'İptal'
});
if (result.isConfirmed) {
try {
await $fetch(`/api/v1/hero/${id}`, {
method: 'DELETE',
baseURL: config.public.BASE_API_URL,
headers: {
Authorization: `Bearer ${(authData.value as any)?.accessToken}`
}
});
Swal.fire('Silindi!', 'Kayıt başarıyla silindi.', 'success');
fetchHeroes();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Silme işlemi başarısız.', 'error');
}
}
};
onMounted(() => {
fetchSettings();
fetchHeroes();
});
</script>
<style scoped>
/* Tabs styling */
.nav-tabs .nav-link {
color: #495057;
}
.nav-tabs .nav-link.active {
font-weight: bold;
color: #0d6efd;
}
</style>

View File

@@ -0,0 +1,457 @@
<template>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Kullanıcı Yönetimi</h2>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center bg-white">
<h5 class="mb-0">Kullanıcı Listesi</h5>
<div class="d-flex align-items-center">
<div class="form-check form-switch me-3 mb-0">
<input class="form-check-input" type="checkbox" v-model="showDeleted" id="userDeleted">
<label class="form-check-label" for="userDeleted">Silinenler</label>
</div>
<button class="btn btn-success btn-sm" @click="openModal(null)"><i class="fas fa-plus me-2"></i> Yeni
Kullanıcı</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>ID</th>
<th>Avatar</th>
<th>Kullanıcı Adı</th>
<th>Email</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.ID">
<td>{{ user.ID }}</td>
<td>
<img :src="getAvatarUrl(user)" alt="Avatar" class="rounded-circle border" width="40" height="40"
style="object-fit: cover;"
onerror="this.src='https://ui-avatars.com/api/?name=User&background=random'">
</td>
<td>{{ user.username }}</td>
<td>
{{ user.email }}
<span v-if="user.email_verified" class="badge bg-success ms-1"><i class="fas fa-check"></i></span>
<span v-else class="badge bg-warning ms-1" title="Email Doğrulanmadı"><i
class="fas fa-exclamation"></i></span>
</td>
<td>
<span v-if="user.profiles && user.profiles.length > 0">
{{ user.profiles[0]?.first_name }} {{ user.profiles[0]?.last_name }}
</span>
<span v-else>-</span>
</td>
<td>
<span class="badge" :class="user.is_admin ? 'bg-primary' : 'bg-secondary'">
{{ user.is_admin ? 'Admin' : 'User' }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<template v-if="!user.DeletedAt">
<button class="btn btn-outline-primary" @click="openModal(user)" title="Düzenle">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-outline-danger" @click="deleteUser(user.ID)" title="Sil">
<i class="fas fa-trash"></i>
</button>
</template>
<template v-else>
<button class="btn btn-outline-success" @click="restoreUser(user.ID)" title="Geri Yükle">
<i class="fas fa-trash-restore"></i>
</button>
<button class="btn btn-outline-danger" @click="deleteUser(user.ID)" title="Kalıcı Sil">
<i class="fas fa-ban"></i>
</button>
</template>
</div>
</td>
</tr>
<tr v-if="users.length === 0">
<td colspan="6" class="text-center py-4">Kayıt bulunamadı.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- MODAL -->
<div class="modal fade" id="userModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ isEditing ? 'Kullanıcı Düzenle' : 'Yeni Kullanıcı Ekle' }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form @submit.prevent="saveUser">
<div class="mb-3">
<label class="form-label">Kullanıcı Adı</label>
<input v-model="formData.username" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input v-model="formData.email" type="email" class="form-control" required>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">İsim</label>
<input v-model="formData.first_name" type="text" class="form-control" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Soyisim</label>
<input v-model="formData.last_name" type="text" class="form-control" required>
</div>
</div>
<div class="mb-3">
<label class="form-label">Avatar</label>
<div class="d-flex align-items-center mb-2" v-if="formData.avatar_url">
<img :src="config.public.BASE_API_URL + formData.avatar_url" alt="Avatar Preview"
class="rounded-circle me-3"
style="width: 50px; height: 50px; object-fit: cover; border: 1px solid #dee2e6;">
<button type="button" class="btn btn-sm btn-outline-danger"
@click="formData.avatar_url = ''">Kaldır</button>
</div>
<input type="file" class="form-control" @change="handleFileUpload" accept="image/*">
<input v-model="formData.avatar_url" type="text" class="form-control mt-2"
placeholder="veya URL girin (/uploads/...)">
<!-- Image Optimization Settings -->
<div class="mt-3 p-3 border rounded bg-light">
<div class="d-flex justify-content-between align-items-center mb-2">
<small class="fw-bold text-primary"><i class="fas fa-magic me-1"></i> Resim Optimizasyonu</small>
<span class="badge bg-secondary" style="font-size: 0.7em">İsteğe Bağlı</span>
</div>
<div class="row g-2">
<div class="col-6 col-md-3">
<label class="form-label small text-muted">Format</label>
<select v-model="imageSettings.format" class="form-select form-select-sm">
<option value="avif">AVIF</option>
<option value="webp">WEBP</option>
<option value="png">PNG</option>
<option value="jpg">JPG</option>
</select>
</div>
<div class="col-6 col-md-3">
<label class="form-label small text-muted">Kalite (1-100)</label>
<input v-model="imageSettings.quality" type="number" class="form-control form-control-sm" min="1"
max="100">
</div>
<div class="col-6 col-md-3">
<label class="form-label small text-muted">Genişlik (px)</label>
<input v-model="imageSettings.width" type="number" class="form-control form-control-sm">
</div>
<div class="col-6 col-md-3">
<label class="form-label small text-muted">Yükseklik (px)</label>
<input v-model="imageSettings.height" type="number" class="form-control form-control-sm">
</div>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Şifre <span v-if="isEditing" class="text-muted small">(Boş bırakılırsa
değişmez)</span></label>
<input v-model="formData.password" type="password" class="form-control" :required="!isEditing"
minlength="6">
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" v-model="formData.is_admin" id="isAdminCheck">
<label class="form-check-label" for="isAdminCheck">Admin Yetkisi</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" v-model="formData.email_verified"
id="emailVerifiedCheck">
<label class="form-check-label" for="emailVerifiedCheck">Email Onaylı</label>
</div>
</div>
</div>
<div class="text-end">
<button type="button" class="btn btn-secondary me-2" data-bs-dismiss="modal">İptal</button>
<button type="submit" class="btn btn-primary" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
Kaydet
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { User } from '~~/types/user';
import Swal from 'sweetalert2';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
const config = useRuntimeConfig();
const { data: authData } = useAuth();
const loading = ref(false);
const users = ref<User[]>([]);
const showDeleted = ref(false);
const formData = ref<any>({});
const isEditing = ref(false);
const avatarFile = ref<File | null>(null);
let userModal: any = null;
// -- API HANDLERS --
const fetchUsers = async () => {
try {
const endpoint = showDeleted.value ? '/api/v1/users/list/deleted' : '/api/v1/users/list';
const res = await $fetch<any>(endpoint, {
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
users.value = res.users || [];
} catch (error) {
console.error('Fetch error:', error);
users.value = [];
}
};
watch(showDeleted, () => {
fetchUsers();
});
const openModal = (user: User | null = null) => {
if (user) {
isEditing.value = true;
// Flatten the structure for the form
// Check if avatar_url is on user or profile
const avatarUrl = user.avatar_url || user.profiles?.[0]?.avatar_url || '';
formData.value = {
...user,
first_name: user.profiles?.[0]?.first_name || '',
last_name: user.profiles?.[0]?.last_name || '',
avatar_url: avatarUrl,
password: '' // Don't prefill password
};
} else {
isEditing.value = false;
formData.value = { is_admin: false, email_verified: false };
}
const modalEl = document.getElementById('userModal');
if (modalEl) {
// Reset image settings
imageSettings.value = {
format: 'avif',
quality: 80,
width: null,
height: null
};
// @ts-ignore
userModal = new bootstrap.Modal(modalEl);
userModal.show();
}
};
const getAvatarUrl = (user: User) => {
// Priority: avatar_url (from User or Profile) -> UI Avatar
// Check user.avatar_url first (if backend sends it on user object)
// Then check profiles[0].avatar
const avatarPath = user.avatar_url || user.profiles?.[0]?.avatar_url;
if (avatarPath) {
// If it's a full URL (e.g. googlelh3...) return as is
if (avatarPath.startsWith('http')) return avatarPath;
// If local path, prepend base API URL
return config.public.BASE_API_URL + avatarPath;
}
// Default: UI Avatars
const name = (user.profiles?.[0]?.first_name && user.profiles?.[0]?.last_name)
? `${user.profiles[0].first_name} ${user.profiles[0].last_name}`
: user.username;
return `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&color=fff`;
};
const handleFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
avatarFile.value = target.files[0];
}
};
const imageSettings = ref({
format: 'avif',
quality: 80,
width: null as number | null,
height: null as number | null
});
const optimizeImage = async (file: File): Promise<File> => {
const fd = new FormData();
fd.append('file', file);
fd.append('format', imageSettings.value.format);
fd.append('quality', String(imageSettings.value.quality));
if (imageSettings.value.width) fd.append('width', String(imageSettings.value.width));
if (imageSettings.value.height) fd.append('height', String(imageSettings.value.height));
const optimizedBlob = await $fetch<Blob>('/api/optimize', {
method: 'POST',
body: fd,
responseType: 'blob'
});
const ext = imageSettings.value.format === 'jpg' ? 'jpeg' : imageSettings.value.format;
const filename = `avatar-${Date.now()}.${ext === 'jpeg' ? 'jpg' : ext}`;
return new File([optimizedBlob], filename, { type: `image/${ext}` });
};
const saveUser = async () => {
loading.value = true;
try {
if (isEditing.value) {
// Context: PUT /api/v1/users/:id
// Using Multipart/Form-Data for strict compliance with backend requirements
const userId = formData.value.ID;
const fd = new FormData();
// Append Avatar if selected
if (avatarFile.value) {
try {
const optimized = await optimizeImage(avatarFile.value);
fd.append('avatar', optimized);
} catch (e) {
console.error(e);
fd.append('avatar', avatarFile.value);
}
}
// Append other fields
// Explicitly mapping known fields to ensure order and presence matches expectation,
// or just iterating keys. Iterating keys is safer for "all fields".
// However, we must ensure boolean -> string conversion.
Object.keys(formData.value).forEach(key => {
const val = formData.value[key];
// Filter out non-form fields and sensitive/system fields
if (key !== 'ID' && key !== 'profiles' && key !== 'avatar_url' && key !== 'CreatedAt' && key !== 'UpdatedAt' && key !== 'DeletedAt') {
if (key === 'password' && !val) return; // Skip empty password
fd.append(key, String(val));
}
});
await $fetch(`/api/v1/users/${userId}`, {
method: 'PUT',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
body: fd
});
// Reset file
avatarFile.value = null;
Swal.fire('Başarılı', 'Kullanıcı güncellendi.', 'success');
} else {
// Context: Create (Register) - POST JSON
await $fetch('/api/v1/auth/register', {
method: 'POST',
baseURL: config.public.BASE_API_URL,
body: formData.value
});
Swal.fire('Başarılı', 'Kullanıcı oluşturuldu.', 'success');
}
if (userModal) userModal.hide();
fetchUsers();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'İşlem başarısız.', 'error');
} finally {
loading.value = false;
}
};
const deleteUser = async (id: number) => {
const isHardDelete = showDeleted.value; // If showing deleted, then next action is hard delete
const result = await Swal.fire({
title: isHardDelete ? 'Kalıcı Silinecek?' : 'Silinecek?',
text: isHardDelete ? "Bu işlem geri alınamaz!" : "Kullanıcı arşivlenecek.",
icon: 'warning',
showCancelButton: true,
confirmButtonText: isHardDelete ? 'Evet, Kalıcı Sil' : 'Evet, Sil',
cancelButtonText: 'İptal',
confirmButtonColor: '#d33'
});
if (!result.isConfirmed) return;
try {
let endpoint = `/api/v1/users/${id}`;
if (isHardDelete) endpoint += '/hard';
await $fetch(endpoint, {
method: 'DELETE',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Silindi!', 'İşlem başarılı.', 'success');
fetchUsers();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Silme işlemi başarısız.', 'error');
}
};
const restoreUser = async (id: number) => {
const result = await Swal.fire({
title: 'Geri Yükle?',
text: "Kullanıcı aktif edilecek.",
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Evet',
cancelButtonText: 'İptal'
});
if (!result.isConfirmed) return;
try {
await $fetch(`/api/v1/users/${id}/restore`, {
method: 'POST',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Başarılı', 'Kullanıcı geri yüklendi.', 'success');
fetchUsers();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Geri yükleme başarısız.', 'error');
}
};
onMounted(() => {
fetchUsers();
});
</script>

177
app/pages/auth/login.vue Normal file
View File

@@ -0,0 +1,177 @@
<template>
<div class="login-area pt-120 pb-120">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
<div class="login-form-wrap shadow-lg p-5 rounded bg-white">
<div class="login-header text-center mb-4">
<h2 class="mb-2">Giriş Yap</h2>
<p>
Hesabınız yok mu?
<NuxtLink to="/auth/register" class="text-primary fw-bold">Kayıt Olun</NuxtLink>
</p>
</div>
<form @submit.prevent="handleLogin">
<div class="mb-3">
<label for="email" class="form-label">E-posta Adresi</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-envelope"></i></span>
<input type="email" class="form-control" id="email" v-model="form.email"
placeholder="E-posta adresinizi girin" required>
</div>
<div v-if="errors.email" class="text-danger small mt-1">{{ errors.email }}</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Şifre</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-lock"></i></span>
<input type="password" class="form-control" id="password" v-model="form.password"
placeholder="Şifrenizi girin" required>
</div>
<div v-if="errors.password" class="text-danger small mt-1">{{ errors.password }}</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="remember-me">
<label class="form-check-label" for="remember-me">Beni Hatırla</label>
</div>
<a href="#" class="text-muted small">Şifremi Unuttum?</a>
</div>
<!-- Turnstile Component -->
<ClientOnly>
<div class="mb-4 d-flex justify-content-center">
<NuxtTurnstile v-model="turnstileToken" />
</div>
</ClientOnly>
<button type="submit" class="btn btn-primary w-100 py-2" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2" role="status"
aria-hidden="true"></span>
{{ loading ? 'Giriş Yapılıyor...' : 'Giriş Yap' }}
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { loginSchema, type LoginInput } from '~~/utils/validations';
import Swal from 'sweetalert2';
definePageMeta({
middleware: 'guest-only',
auth: { unauthenticatedOnly: true, navigateAuthenticatedTo: '/' }
});
const { signIn } = useAuth();
const router = useRouter();
// Reactive form state
const form = reactive<LoginInput>({
email: '',
password: ''
});
// Validation errors state
const errors = reactive<Partial<Record<keyof LoginInput, string>>>({});
const loading = ref(false);
const turnstileToken = ref('');
const validate = () => {
const result = loginSchema.safeParse(form);
// Clear previous errors
Object.keys(errors).forEach(key => delete errors[key as keyof LoginInput]);
if (!result.success) {
result.error.errors.forEach(err => {
if (err.path[0]) {
errors[err.path[0] as keyof LoginInput] = err.message;
}
});
return false;
}
return true;
};
const handleLogin = async () => {
if (!validate()) return;
if (!turnstileToken.value) {
Swal.fire({
icon: 'warning',
title: 'Güvenlik Kontrolü',
text: 'Lütfen Turnstile güvenlik kontrolünü tamamlayın.',
timer: 3000,
toast: true,
position: 'top-end',
showConfirmButton: false
});
return;
}
loading.value = true;
try {
const result = await signIn('credentials', {
email: form.email,
password: form.password,
redirect: false,
});
if (result?.error) {
Swal.fire({
icon: 'error',
title: 'Giriş Başarısız',
text: 'E-posta veya şifre hatalı.',
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: 3000
});
} else {
Swal.fire({
icon: 'success',
title: 'Giriş Başarılı',
text: 'Yönlendiriliyorsunuz...',
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: 1500
});
setTimeout(() => {
router.push('/');
}, 1000);
}
} catch (error) {
console.error(error);
Swal.fire({
icon: 'error',
title: 'Hata',
text: 'Bir şeyler ters gitti, lütfen tekrar deneyin.',
});
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.login-area {
background-color: #f8f9fa;
min-height: 80vh;
display: flex;
align-items: center;
}
.login-form-wrap {
border-top: 5px solid #0d6efd;
}
</style>

222
app/pages/auth/register.vue Normal file
View File

@@ -0,0 +1,222 @@
<template>
<div class="register-area pt-120 pb-120">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8 col-md-10">
<div class="register-form-wrap shadow-lg p-5 rounded bg-white">
<div class="register-header text-center mb-4">
<h2 class="mb-2">Hesap Oluşturun</h2>
<p>
Zaten hesabınız var mı?
<NuxtLink to="/auth/login" class="text-primary fw-bold">Giriş Yapın</NuxtLink>
</p>
</div>
<form @submit.prevent="handleRegister">
<div class="mb-3">
<label for="username" class="form-label">Kullanıcı Adı</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-user"></i></span>
<input type="text" class="form-control" id="username" v-model="form.username"
placeholder="Kullanıcı adı seçin" required>
</div>
<div v-if="errors.username" class="text-danger small mt-1">{{ errors.username }}</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="first_name" class="form-label">Ad</label>
<input type="text" class="form-control" id="first_name" v-model="form.first_name"
placeholder="Adınız" required>
<div v-if="errors.first_name" class="text-danger small mt-1">{{ errors.first_name }}
</div>
</div>
<div class="col-md-6 mb-3">
<label for="last_name" class="form-label">Soyad</label>
<input type="text" class="form-control" id="last_name" v-model="form.last_name"
placeholder="Soyadınız" required>
<div v-if="errors.last_name" class="text-danger small mt-1">{{ errors.last_name }}
</div>
</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">E-posta Adresi</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-envelope"></i></span>
<input type="email" class="form-control" id="email" v-model="form.email"
placeholder="E-posta adresiniz" required>
</div>
<div v-if="errors.email" class="text-danger small mt-1">{{ errors.email }}</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="password" class="form-label">Şifre</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-lock"></i></span>
<input type="password" class="form-control" id="password"
v-model="form.password" placeholder="Şifreniz" required>
</div>
<div v-if="errors.password" class="text-danger small mt-1">{{ errors.password }}
</div>
</div>
<div class="col-md-6 mb-3">
<label for="passwordConfirm" class="form-label">Şifre Tekrar</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-lock"></i></span>
<input type="password" class="form-control" id="passwordConfirm"
v-model="form.passwordConfirm" placeholder="Şifrenizi tekrar girin"
required>
</div>
<div v-if="errors.passwordConfirm" class="text-danger small mt-1">{{
errors.passwordConfirm }}</div>
</div>
</div>
<!-- Turnstile Component -->
<ClientOnly>
<div class="mb-4 d-flex justify-content-center">
<NuxtTurnstile v-model="turnstileToken" />
</div>
</ClientOnly>
<button type="submit" class="btn btn-success w-100 py-2" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2" role="status"
aria-hidden="true"></span>
{{ loading ? 'Kayıt Yapılıyor...' : 'Kayıt Ol' }}
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { registerSchema, type RegisterInput } from '~~/utils/validations';
import Swal from 'sweetalert2';
definePageMeta({
middleware: 'guest-only',
auth: { unauthenticatedOnly: true, navigateAuthenticatedTo: '/' }
});
const config = useRuntimeConfig();
const router = useRouter();
const form = reactive<RegisterInput>({
username: '',
first_name: '',
last_name: '',
email: '',
password: '',
passwordConfirm: ''
});
const errors = reactive<Partial<Record<keyof RegisterInput, string>>>({});
const loading = ref(false);
const turnstileToken = ref('');
const validate = () => {
// Zod validasyonu
const result = registerSchema.safeParse(form);
// Clear previous errors
Object.keys(errors).forEach(key => delete errors[key as keyof RegisterInput]);
if (!result.success) {
result.error.errors.forEach(err => {
if (err.path[0]) {
errors[err.path[0] as keyof RegisterInput] = err.message;
}
});
return false;
}
return true;
};
const handleRegister = async () => {
if (!validate()) return;
if (!turnstileToken.value) {
Swal.fire({
icon: 'warning',
title: 'Güvenlik Kontrolü',
text: 'Lütfen Turnstile güvenlik kontrolünü tamamlayın.',
timer: 3000,
toast: true,
position: 'top-end',
showConfirmButton: false
});
return;
}
loading.value = true;
try {
const apiUrl = config.public.NUXT_PUBLIC_API_BASE || 'http://127.0.0.1:8080';
// Backend API çağrısı
const response = await fetch(`${apiUrl}/api/v1/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: form.username,
first_name: form.first_name,
last_name: form.last_name,
email: form.email,
password: form.password
})
});
if (response.ok) {
const data = await response.json();
Swal.fire({
icon: 'success',
title: 'Kayıt Başarılı!',
text: data.message || 'Lütfen e-posta adresinizi doğrulayın.',
confirmButtonText: 'Giriş Yap'
}).then((result) => {
if (result.isConfirmed) {
router.push('/auth/login');
}
});
} else {
const errorData = await response.json();
Swal.fire({
icon: 'error',
title: 'Kayıt Hatası',
text: errorData.message || 'Kayıt işlemi sırasında bir hata oluştu.',
});
}
} catch (error) {
console.error(error);
Swal.fire({
icon: 'error',
title: 'Hata',
text: 'Sunucu ile iletişim hatası.',
});
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.register-area {
background-color: #f8f9fa;
min-height: 80vh;
display: flex;
align-items: center;
}
.register-form-wrap {
border-top: 5px solid #198754;
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<div class="resend-verify-area pt-120 pb-120">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
<div class="resend-form-wrap shadow-lg p-5 rounded bg-white">
<div class="header text-center mb-4">
<h2 class="mb-2">Doğrulama E-postası Gönder</h2>
<p class="text-muted">
E-posta adresinizi girin, doğrulama bağlantısını tekrar gönderelim.
</p>
</div>
<form @submit.prevent="handleResend">
<div class="mb-3">
<label for="email" class="form-label">E-posta Adresi</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-envelope"></i></span>
<input type="email" class="form-control" id="email" v-model="email"
placeholder="E-posta adresinizi girin" required>
</div>
<div v-if="error" class="text-danger small mt-1">{{ error }}</div>
</div>
<!-- Turnstile Component -->
<ClientOnly>
<div class="mb-4 d-flex justify-content-center">
<NuxtTurnstile v-model="turnstileToken" />
</div>
</ClientOnly>
<button type="submit" class="btn btn-primary w-100 py-2" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2" role="status"
aria-hidden="true"></span>
{{ loading ? 'Gönderiliyor...' : 'Gönder' }}
</button>
</form>
<div class="text-center mt-4">
<NuxtLink to="/auth/login" class="text-decoration-none">
<i class="fas fa-arrow-left me-1"></i> Giriş sayfasına dön
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { z } from 'zod'; // Import z directly if not exported from validations, or use from validations if available
import Swal from 'sweetalert2';
definePageMeta({
middleware: 'guest-only',
auth: { unauthenticatedOnly: true, navigateAuthenticatedTo: '/' }
});
const config = useRuntimeConfig();
const email = ref('');
const error = ref('');
const loading = ref(false);
const turnstileToken = ref('');
const emailSchema = z.string().email({ message: 'Geçerli bir e-posta adresi giriniz' });
const validate = () => {
const result = emailSchema.safeParse(email.value);
if (!result.success) {
error.value = result.error.errors[0].message;
return false;
}
error.value = '';
return true;
};
const handleResend = async () => {
if (!validate()) return;
if (!turnstileToken.value) {
Swal.fire({
icon: 'warning',
title: 'Güvenlik Kontrolü',
text: 'Lütfen Turnstile güvenlik kontrolünü tamamlayın.',
timer: 3000,
toast: true,
position: 'top-end',
showConfirmButton: false
});
return;
}
loading.value = true;
try {
const apiUrl = config.public.NUXT_PUBLIC_API_BASE || 'http://127.0.0.1:8080';
const response = await fetch(`${apiUrl}/api/v1/auth/resend-verification`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email.value
})
});
if (response.ok) {
Swal.fire({
icon: 'success',
title: 'Başarılı!',
text: 'Doğrulama bağlantısı e-posta adresinize gönderildi.',
confirmButtonText: 'Tamam'
});
email.value = ''; // Reset form
turnstileToken.value = ''; // Reset token ideally, but might need manual reset
} else {
const data = await response.json();
Swal.fire({
icon: 'error',
title: 'Hata',
text: data.message || data.error || 'İşlem başarısız oldu.',
});
}
} catch (err) {
console.error(err);
Swal.fire({
icon: 'error',
title: 'Hata',
text: 'Sunucu ile iletişim hatası.',
});
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.resend-verify-area {
background-color: #f8f9fa;
min-height: 80vh;
display: flex;
align-items: center;
}
.resend-form-wrap {
border-top: 5px solid #0d6efd;
}
</style>

114
app/pages/auth/verify.vue Normal file
View File

@@ -0,0 +1,114 @@
<template>
<div class="verify-area pt-120 pb-120">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
<div class="verify-content text-center shadow-lg p-5 rounded bg-white">
<div v-if="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Yükleniyor...</span>
</div>
<p class="mt-3 text-muted fs-5">E-posta adresiniz doğrulanıyor...</p>
</div>
<div v-else-if="success">
<div class="mb-4">
<div class="success-icon d-inline-flex align-items-center justify-content-center bg-success text-white rounded-circle"
style="width: 80px; height: 80px;">
<i class="fas fa-check fa-3x"></i>
</div>
</div>
<h3 class="mb-3 text-success">Doğrulama Başarılı!</h3>
<p class="text-muted mb-4 fs-5">Hesabınız başarıyla doğrulandı. Artık giriş yapabilirsiniz.
</p>
<NuxtLink to="/auth/login" class="btn btn-primary btn-lg w-100">
Giriş Yap
</NuxtLink>
</div>
<div v-else>
<div class="mb-4">
<div class="error-icon d-inline-flex align-items-center justify-content-center bg-danger text-white rounded-circle"
style="width: 80px; height: 80px;">
<i class="fas fa-times fa-3x"></i>
</div>
</div>
<h3 class="mb-3 text-danger">Doğrulama Hatası</h3>
<p class="text-muted mb-4 fs-5">{{ displayError }}</p>
<NuxtLink to="/auth/login" class="btn btn-outline-primary">
Giriş sayfasına dön
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const route = useRoute();
const config = useRuntimeConfig();
definePageMeta({
middleware: 'guest-only',
auth: { unauthenticatedOnly: true, navigateAuthenticatedTo: '/' }
});
const loading = ref(true);
const success = ref(false);
const errorMessage = ref('');
const displayError = computed(() => {
return errorMessage.value || 'Doğrulama işlemi sırasında bir hata oluştu.';
});
onMounted(async () => {
const token = route.query.token as string;
if (!token) {
loading.value = false;
errorMessage.value = "Geçersiz doğrulama bağlantısı (token eksik).";
return;
}
try {
const apiUrl = config.public.NUXT_PUBLIC_API_BASE || 'http://127.0.0.1:8080';
// Backend API: GET /api/v1/auth/verify-email?token=...
const response = await fetch(`${apiUrl}/api/v1/auth/verify-email?token=${token}`, {
method: 'GET',
headers: {
'accept': 'application/json'
}
});
if (response.ok) {
success.value = true;
} else {
const data = await response.json();
errorMessage.value = data.message || 'Token geçersiz veya süresi dolmuş.';
}
} catch (error) {
console.error("Verify error:", error);
errorMessage.value = 'Sunucu ile bağlantı kurulamadı.';
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.verify-area {
background-color: #f8f9fa;
min-height: 80vh;
display: flex;
align-items: center;
}
.verify-content {
border-top: 5px solid #0d6efd;
}
</style>

61
app/pages/index.vue Normal file
View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import Paginate from "~/components/Paginate.vue";
import FooterArea from "~/components/FooterArea.vue";
import SubsribeStart from "~/components/SubsribeStart.vue";
import TagsClouds from "~/components/TagsClouds.vue";
import WidgetSidebarImgBox from "~/components/home/WidgetSidebarImgBox.vue";
import WidgetRecentPost from "~/components/home/WidgetRecentPost.vue";
import WidgetCategories from "~/components/home/WidgetCategories.vue";
import WidgetSearch from "~/components/home/WidgetSearch.vue";
import BlogPage from "~/components/home/BlogPage.vue";
import PagesHeroHeader from "~/components/home/PagesHeroHeader.vue";
import MobileHeader from "~/components/MobileHeader.vue";
import HeaderSection from "~/components/HeaderSection.vue";
import Preloader from "~/components/home/Preloader.vue";
import PaginaContainer from "~/components/PaginaContainer.vue";
</script>
<template>
<div>
<Preloader/>
<!--=====progress START=======-->
<PaginaContainer/>
<!--=====progress END=======-->
<!--=====HEADER START=======-->
<HeaderSection/>
<!--=====HEADER END=======-->
<!--=====Mobile header start=======-->
<MobileHeader/>
<!--=====Mobile header end=======-->
<!--=====pages hero header end=======-->
<PagesHeroHeader/>
<!--=====pages hero header end=======-->
<div class="blog-page sp2">
<div class="container">
<div class="row">
<BlogPage/>
<div class="col-lg-4 col-md-6">
<div class="widgets lg-ml-15">
<WidgetSearch/>
<WidgetCategories/>
<WidgetSidebarImgBox/>
<TagsClouds/>
</div>
</div>
</div>
</div>
</div>
<!--=====SUBSRIBE START=======-->
<SubsribeStart/>
<!--=====SUBSRIBE END=======-->
<!--=====Footer start=======-->
<FooterArea/>
<!--=====Footer end=======-->
<!--================== sidebar===================== -->
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,20 @@
import type { Setting } from "~~/types/setting";
export default defineNuxtPlugin(async () => {
const { useSettingStore } = await import('@/stores/setting')
const settingStore = useSettingStore()
const config = useRuntimeConfig();
const apiUrl = config.public.BASE_API_URL;
try {
const data = await $fetch<Setting>(`${apiUrl}/api/v1/setting`)
if (data) {
settingStore.setSettings(data)
}
} catch (error) {
console.error('Failed to fetch settings:', error)
}
})

View File

@@ -0,0 +1,20 @@
import type { Hero } from "~~/types/hero";
export default defineNuxtPlugin(async () => {
const { useHeroStore } = await import('@/stores/hero')
const heroStore = useHeroStore()
const config = useRuntimeConfig();
const apiUrl = config.public.BASE_API_URL;
try {
const data = await $fetch<Hero>(`${apiUrl}/api/v1/hero`)
if (data) {
heroStore.setHero(data)
}
} catch (error) {
console.error('Failed to fetch settings:', error)
}
})

11
app/plugins/auth-error.ts Normal file
View File

@@ -0,0 +1,11 @@
export default defineNuxtPlugin((nuxtApp) => {
const { data, signOut } = useAuth();
// Watch for session changes
watch(data, async (session) => {
if (session?.error === 'RefreshAccessTokenError') {
console.warn('Refresh token expired or invalid. Signing out...');
await signOut({ callbackUrl: '/auth/login' });
}
}, { immediate: true });
});

View File

@@ -0,0 +1,7 @@
// Converted to a no-op: we register Font Awesome using an SSR-aware plugin `app/plugins/fontawesome.ts`.
// Keep this client-only file present (if you relied on it elsewhere) but do not inject the runtime script
// to avoid hydration mismatches.
export default defineNuxtPlugin(() => {
if (process.server) return
// No-op on client — FontAwesome is registered via app/plugins/fontawesome.ts
})

View File

@@ -0,0 +1,39 @@
// SSR-friendly Font Awesome plugin
// Registers the Vue component and a small set of icons so server-render and client-render match.
import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
// Import a conservative set of icons used in the admin UI
import {
faHouse,
faUsers,
faCogs,
faNewspaper,
faTags,
faHashtag,
faRightFromBracket,
faPlus,
faWandMagic,
faGaugeHigh,
faShieldAlt
} from '@fortawesome/free-solid-svg-icons'
export default defineNuxtPlugin((nuxtApp) => {
// Add selected icons to the library
library.add(
faHouse,
faUsers,
faCogs,
faNewspaper,
faTags,
faHashtag,
faRightFromBracket,
faPlus,
faWandMagic,
faGaugeHigh,
faShieldAlt
)
// Globally register component
nuxtApp.vueApp.component('FontAwesomeIcon', FontAwesomeIcon)
})

View File

@@ -0,0 +1,5 @@
import Swal from 'sweetalert2'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.provide('swal', Swal)
})

18
app/stores/hero.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineStore } from 'pinia';
import type { Hero } from '~~/types/hero';
interface HeroState {
hero: Hero | null;
}
export const useHeroStore = defineStore('hero', {
state: (): HeroState => ({
hero: null,
}),
actions: {
setHero(newHero: Hero | null): void {
this.hero = newHero;
}
}
});

18
app/stores/setting.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineStore } from 'pinia';
import type { Setting } from '~~/types/setting';
interface SettingState {
settings: Setting | null;
}
export const useSettingStore = defineStore('setting', {
state: (): SettingState => ({
settings: null,
}),
actions: {
setSettings(newSettings: Setting | null): void {
this.settings = newSettings;
}
}
});