first commit
This commit is contained in:
8
app/app.vue
Normal file
8
app/app.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
121
app/components/FooterArea.vue
Normal file
121
app/components/FooterArea.vue
Normal 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">We’re 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>
|
||||
113
app/components/HeaderSection.vue
Normal file
113
app/components/HeaderSection.vue
Normal 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>We’re 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>
|
||||
74
app/components/MobileHeader.vue
Normal file
74
app/components/MobileHeader.vue
Normal 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>
|
||||
19
app/components/PaginaContainer.vue
Normal file
19
app/components/PaginaContainer.vue
Normal 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>
|
||||
24
app/components/Paginate.vue
Normal file
24
app/components/Paginate.vue
Normal 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>
|
||||
40
app/components/SubsribeStart.vue
Normal file
40
app/components/SubsribeStart.vue
Normal 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>
|
||||
23
app/components/TagsClouds.vue
Normal file
23
app/components/TagsClouds.vue
Normal 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>
|
||||
74
app/components/home/BlogPage.vue
Normal file
74
app/components/home/BlogPage.vue
Normal 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>
|
||||
27
app/components/home/PagesHeroHeader.vue
Normal file
27
app/components/home/PagesHeroHeader.vue
Normal 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>
|
||||
14
app/components/home/Preloader.vue
Normal file
14
app/components/home/Preloader.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="preloader">
|
||||
<div id="loader"></div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
32
app/components/home/WidgetCategories.vue
Normal file
32
app/components/home/WidgetCategories.vue
Normal 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>
|
||||
71
app/components/home/WidgetRecentPost.vue
Normal file
71
app/components/home/WidgetRecentPost.vue
Normal 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>
|
||||
19
app/components/home/WidgetSearch.vue
Normal file
19
app/components/home/WidgetSearch.vue
Normal 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>
|
||||
31
app/components/home/WidgetSidebarImgBox.vue
Normal file
31
app/components/home/WidgetSidebarImgBox.vue
Normal 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 don’t 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>
|
||||
23
app/components/home/WidgetTagCloud.vue
Normal file
23
app/components/home/WidgetTagCloud.vue
Normal 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
88
app/layouts/admin.vue
Normal 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 & Ağ
|
||||
</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
72
app/layouts/default.vue
Normal 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
11
app/middleware/admin.ts
Normal 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('/');
|
||||
}
|
||||
});
|
||||
8
app/middleware/guest-only.ts
Normal file
8
app/middleware/guest-only.ts
Normal 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('/');
|
||||
}
|
||||
});
|
||||
350
app/pages/admin/blog/categories/index.vue
Normal file
350
app/pages/admin/blog/categories/index.vue
Normal 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>Açı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">Açı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>
|
||||
325
app/pages/admin/blog/posts/[id].vue
Normal file
325
app/pages/admin/blog/posts/[id].vue
Normal 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>
|
||||
253
app/pages/admin/blog/posts/create.vue
Normal file
253
app/pages/admin/blog/posts/create.vue
Normal 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>
|
||||
252
app/pages/admin/blog/posts/index.vue
Normal file
252
app/pages/admin/blog/posts/index.vue
Normal 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>
|
||||
317
app/pages/admin/blog/tags/index.vue
Normal file
317
app/pages/admin/blog/tags/index.vue
Normal 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
14
app/pages/admin/index.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div>
|
||||
admin index
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin'
|
||||
});
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
501
app/pages/admin/security/index.vue
Normal file
501
app/pages/admin/security/index.vue
Normal file
@@ -0,0 +1,501 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Güvenlik & Ağ 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>Açı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>Açı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">Açı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">Açı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>
|
||||
```
|
||||
578
app/pages/admin/settings/index.vue
Normal file
578
app/pages/admin/settings/index.vue
Normal 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 Açı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>
|
||||
457
app/pages/admin/users/index.vue
Normal file
457
app/pages/admin/users/index.vue
Normal 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
177
app/pages/auth/login.vue
Normal 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
222
app/pages/auth/register.vue
Normal 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>
|
||||
150
app/pages/auth/resend-verify.vue
Normal file
150
app/pages/auth/resend-verify.vue
Normal 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
114
app/pages/auth/verify.vue
Normal 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
61
app/pages/index.vue
Normal 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>
|
||||
20
app/plugins/01.fetch_setting.ts
Normal file
20
app/plugins/01.fetch_setting.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
20
app/plugins/02.fetch_hero.ts
Normal file
20
app/plugins/02.fetch_hero.ts
Normal 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
11
app/plugins/auth-error.ts
Normal 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 });
|
||||
});
|
||||
7
app/plugins/fontawesome.client.ts
Normal file
7
app/plugins/fontawesome.client.ts
Normal 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
|
||||
})
|
||||
39
app/plugins/fontawesome.ts
Normal file
39
app/plugins/fontawesome.ts
Normal 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)
|
||||
})
|
||||
|
||||
5
app/plugins/sweetalert2.client.ts
Normal file
5
app/plugins/sweetalert2.client.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import Swal from 'sweetalert2'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.provide('swal', Swal)
|
||||
})
|
||||
18
app/stores/hero.ts
Normal file
18
app/stores/hero.ts
Normal 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
18
app/stores/setting.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user