Add 'frontend/' from commit 'c070c0315d66a44154ab7d9f9ea6c211a15f4dba'

git-subtree-dir: frontend
git-subtree-mainline: 205a3bd14b
git-subtree-split: c070c0315d
This commit is contained in:
2025-01-15 15:24:17 -05:00
318 changed files with 29301 additions and 0 deletions

50
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,50 @@
<template>
<v-app>
<div class="flex flex-row">
<!-- Side Bar for larger screens -->
<div v-if="!smAndDown" class="border-r-2 z-30">
<side-bar></side-bar>
</div>
<!-- Mobile -->
<div v-if="smAndDown" class="mobile-container">
<div v-if="!brandingStore.loading"
class="min-h-screen justify-center items-center"
:style="{ backgroundColor: brandingStore.colors.background }">
<router-view></router-view>
</div>
</div>
<!-- PC -->
<div v-if="!smAndDown" :class="['w-full', sideBarStore.sidebarWidth]">
<div v-if="!brandingStore.loading"
class="min-h-screen justify-center items-center"
:style="{ backgroundColor: brandingStore.colors.background }">
<router-view class="p-2"></router-view>
</div>
</div>
</div>
</v-app>
</template>
<script async setup>
import SideBar from "@/views/main/SideBar.vue";
import { useBrandingStore } from "@/stores/brandingStore.js";
import { useSideBarStore } from "@/stores/sideBarStore.js";
import { useDisplay } from "vuetify";
const { smAndDown } = useDisplay();
const brandingStore = useBrandingStore();
const sideBarStore = useSideBarStore();
</script>
<style scoped>
/* Ensure content does not overflow on mobile */
.mobile-container {
width: 100%; /* Full width for mobile */
max-width: 100vw; /* Prevent overflow */
overflow-x: hidden; /* Hide horizontal overflow */
padding: 0; /* Remove extra padding */
box-sizing: border-box; /* Include padding in width/height calculation */
}
</style>

View File

@@ -0,0 +1,9 @@
export const REACTIONS = {
LIKE: 'Like',
DISLIKE: 'Dislike',
LOVE: 'Love',
HAHA: 'Haha',
WOW: 'Wow',
SAD: 'Sad',
ANGRY: 'Angry'
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M64 32C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64h98.2V334.2H109.4V256h52.8V222.3c0-87.1 39.4-127.5 125-127.5c16.2 0 44.2 3.2 55.7 6.4V172c-6-.6-16.5-1-29.6-1c-42 0-58.2 15.9-58.2 57.2V256h83.6l-14.4 78.2H255V480H384c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64H64z"/></svg>

After

Width:  |  Height:  |  Size: 498 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 406 432">
<path d="M0 0 C2.69849689 0.0031451 5.39587704 -0.02033719 8.09423828 -0.0456543 C24.64095924 -0.10755047 40.6877731 1.83145667 56.59814453 6.50317383 C57.64365479 6.80505615 58.68916504 7.10693848 59.76635742 7.41796875 C92.79656028 17.21576522 120.14877162 34.84217931 145.09814453 58.31567383 C145.80712891 58.97051758 146.51611328 59.62536133 147.24658203 60.30004883 C156.43027452 69.07141226 164.10800524 79.75645166 171.09814453 90.31567383 C171.99726562 91.64985352 171.99726562 91.64985352 172.91455078 93.01098633 C202.24624509 138.40104257 210.21713133 193.46657021 199.45556641 245.92919922 C192.90962056 275.68852135 179.08829353 305.20779517 159.09814453 328.31567383 C158.12705075 329.46844293 157.15708766 330.62216508 156.18798828 331.77661133 C145.74534244 344.15953661 145.74534244 344.15953661 140.09814453 349.31567383 C139.43814453 349.31567383 138.77814453 349.31567383 138.09814453 349.31567383 C138.09814453 349.97567383 138.09814453 350.63567383 138.09814453 351.31567383 C125.46428062 363.48565937 109.61785837 373.30307857 94.09814453 381.31567383 C93.49325195 381.62843262 92.88835937 381.94119141 92.26513672 382.26342773 C64.72703152 396.27155233 34.49385055 402.76232703 3.72314453 402.69067383 C2.84274506 402.69013 1.96234558 402.68958618 1.05526733 402.68902588 C-13.25349056 402.64848426 -26.95433873 401.73425935 -40.90185547 398.31567383 C-42.23821045 397.99421387 -42.23821045 397.99421387 -43.6015625 397.66625977 C-72.07884569 390.61747809 -98.87807183 378.00655878 -121.53857422 359.26879883 C-124.98230298 356.422742 -128.53024689 353.7103623 -132.06591797 350.97973633 C-132.97470703 350.15602539 -132.97470703 350.15602539 -133.90185547 349.31567383 C-133.90185547 348.65567383 -133.90185547 347.99567383 -133.90185547 347.31567383 C-134.56185547 347.31567383 -135.22185547 347.31567383 -135.90185547 347.31567383 C-135.90185547 346.65567383 -135.90185547 345.99567383 -135.90185547 345.31567383 C-136.56185547 345.31567383 -137.22185547 345.31567383 -137.90185547 345.31567383 C-139.24169922 343.98754883 -139.24169922 343.98754883 -140.83935547 342.06567383 C-143.10188476 339.38611053 -145.39815773 336.76489417 -147.80810547 334.21801758 C-148.30834229 333.68660156 -148.8085791 333.15518555 -149.32397461 332.60766602 C-150.32555716 331.54718583 -151.33248849 330.49172924 -152.3449707 329.44165039 C-155.90185547 325.64913211 -155.90185547 325.64913211 -155.90185547 322.31567383 C-151.7526342 322.86293323 -149.09751951 325.32327358 -145.96435547 327.87817383 C-112.49996321 354.28872765 -70.28107399 364.92725762 -28.16088867 360.37231445 C11.7404784 355.37903429 50.51367105 333.83116901 75.28564453 302.00317383 C102.2073581 266.1942078 113.74287629 223.77918143 108.03564453 179.19067383 C103.36182966 146.79964046 88.57557441 116.98148746 66.09814453 93.31567383 C65.60604492 92.78570801 65.11394531 92.25574219 64.60693359 91.7097168 C40.8738918 66.25446714 8.58347239 50.05740641 -25.90185547 45.31567383 C-27.07103516 45.13391602 -28.24021484 44.9521582 -29.44482422 44.76489258 C-71.61533582 39.65438979 -112.89208728 51.7135382 -146.31201172 77.46020508 C-149.8276055 80.21952147 -153.19809546 83.10839876 -156.42138672 86.20629883 C-157.90185547 87.31567383 -157.90185547 87.31567383 -160.90185547 87.31567383 C-160.35570146 83.08040407 -157.80081795 80.22554824 -155.21435547 77.00317383 C-154.72813721 76.3936084 -154.24191895 75.78404297 -153.7409668 75.15600586 C-143.89916607 62.99985508 -133.13277345 52.06245136 -120.90185547 42.31567383 C-120.33450684 41.85741211 -119.7671582 41.39915039 -119.18261719 40.92700195 C-97.55281967 23.63297615 -71.10136924 11.56026197 -44.27685547 5.06567383 C-43.26478027 4.81954346 -42.25270508 4.57341309 -41.20996094 4.31982422 C-27.42871573 1.10789603 -14.10433559 -0.03420308 0 0 Z "
transform="translate(184.90185546875,13.684326171875)"/>
<path d="M0 0 C23.93418963 20.21356155 38.18828481 47.87411238 43.30859375 78.609375 C43.72796962 83.82104607 43.79621346 89.00908218 43.74609375 94.234375 C43.7423877 94.93147583 43.73868164 95.62857666 43.73486328 96.34680176 C43.51079954 126.08447361 33.84943539 152.98108038 14.30859375 175.609375 C13.37273437 176.74439453 13.37273437 176.74439453 12.41796875 177.90234375 C7.3119221 183.93204517 1.58692987 188.83219983 -4.69140625 193.609375 C-5.65691406 194.351875 -6.62242187 195.094375 -7.6171875 195.859375 C-33.76277727 214.99515608 -66.44544142 222.27976227 -98.37890625 217.421875 C-129.2224086 212.211016 -156.10496998 196.18588064 -175.99609375 172.10546875 C-177.69140625 169.609375 -177.69140625 169.609375 -177.69140625 166.609375 C-176.37140625 166.939375 -175.05140625 167.269375 -173.69140625 167.609375 C-173.69140625 168.269375 -173.69140625 168.929375 -173.69140625 169.609375 C-172.85222656 170.00576172 -172.85222656 170.00576172 -171.99609375 170.41015625 C-169.47801141 171.72041265 -167.18096648 173.22649078 -164.81640625 174.796875 C-141.95286732 189.35191305 -116.13355444 194.94004479 -89.48388672 189.12792969 C-74.00965166 185.43003002 -61.09388018 178.62819028 -48.69140625 168.609375 C-48.05976563 168.14273438 -47.428125 167.67609375 -46.77734375 167.1953125 C-29.95140734 154.19707727 -19.18769244 130.97695501 -16.03515625 110.39453125 C-15.865 109.01587891 -15.865 109.01587891 -15.69140625 107.609375 C-15.55734375 106.55621094 -15.42328125 105.50304687 -15.28515625 104.41796875 C-12.8813248 80.40697048 -19.85309354 55.11934523 -34.69921875 36.02734375 C-35.35664063 35.22941406 -36.0140625 34.43148437 -36.69140625 33.609375 C-37.32820312 32.82175781 -37.965 32.03414062 -38.62109375 31.22265625 C-54.38763523 12.57508008 -77.98681734 0.98006353 -102.19970703 -1.04858398 C-115.33840527 -1.9297872 -128.14870541 -0.56547477 -140.69140625 3.609375 C-141.71621094 3.94710937 -142.74101562 4.28484375 -143.796875 4.6328125 C-155.05737548 8.64192736 -164.81345042 14.73678833 -173.984375 22.359375 C-175.69140625 23.609375 -175.69140625 23.609375 -177.69140625 23.609375 C-176.02661937 12.90007435 -162.17010016 2.50403181 -153.91601562 -3.73193359 C-107.16913616 -37.54750108 -44.81974776 -36.84949238 0 0 Z " transform="translate(217.69140625,119.390625)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C-6.58 9.58 -15.16 18.16 -24 27 C-24 23.39768908 -23.43128853 23.0209684 -21.03515625 20.48828125 C-20.40359619 19.81603516 -19.77203613 19.14378906 -19.12133789 18.45117188 C-18.44192139 17.74541016 -17.76250488 17.03964844 -17.0625 16.3125 C-16.3811499 15.59513672 -15.6997998 14.87777344 -14.99780273 14.13867188 C-10.21994145 9.15189593 -5.33436292 4.3947055 0 0 Z " transform="translate(59,60)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C-4.93 7.93 -11.86 14.86 -19 22 C-19 17.63604688 -16.29697402 15.68537662 -13.4375 12.6875 C-12.8919043 12.10589111 -12.34630859 11.52428223 -11.78417969 10.92504883 C-8.06375023 7.00117561 -4.23602964 3.36956903 0 0 Z " transform="translate(62,117)"/>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 448 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
<path d="M194.4 211.7a53.3 53.3 0 1 0 59.3 88.7 53.3 53.3 0 1 0 -59.3-88.7zm142.3-68.4c-5.2-5.2-11.5-9.3-18.4-12c-18.1-7.1-57.6-6.8-83.1-6.5c-4.1 0-7.9 .1-11.2 .1c-3.3 0-7.2 0-11.4-.1c-25.5-.3-64.8-.7-82.9 6.5c-6.9 2.7-13.1 6.8-18.4 12s-9.3 11.5-12 18.4c-7.1 18.1-6.7 57.7-6.5 83.2c0 4.1 .1 7.9 .1 11.1s0 7-.1 11.1c-.2 25.5-.6 65.1 6.5 83.2c2.7 6.9 6.8 13.1 12 18.4s11.5 9.3 18.4 12c18.1 7.1 57.6 6.8 83.1 6.5c4.1 0 7.9-.1 11.2-.1c3.3 0 7.2 0 11.4 .1c25.5 .3 64.8 .7 82.9-6.5c6.9-2.7 13.1-6.8 18.4-12s9.3-11.5 12-18.4c7.2-18 6.8-57.4 6.5-83c0-4.2-.1-8.1-.1-11.4s0-7.1 .1-11.4c.3-25.5 .7-64.9-6.5-83l0 0c-2.7-6.9-6.8-13.1-12-18.4zm-67.1 44.5A82 82 0 1 1 178.4 324.2a82 82 0 1 1 91.1-136.4zm29.2-1.3c-3.1-2.1-5.6-5.1-7.1-8.6s-1.8-7.3-1.1-11.1s2.6-7.1 5.2-9.8s6.1-4.5 9.8-5.2s7.6-.4 11.1 1.1s6.5 3.9 8.6 7s3.2 6.8 3.2 10.6c0 2.5-.5 5-1.4 7.3s-2.4 4.4-4.1 6.2s-3.9 3.2-6.2 4.2s-4.8 1.5-7.3 1.5l0 0c-3.8 0-7.5-1.1-10.6-3.2zM448 96c0-35.3-28.7-64-64-64H64C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64H384c35.3 0 64-28.7 64-64V96zM357 389c-18.7 18.7-41.4 24.6-67 25.9c-26.4 1.5-105.6 1.5-132 0c-25.6-1.3-48.3-7.2-67-25.9s-24.6-41.4-25.8-67c-1.5-26.4-1.5-105.6 0-132c1.3-25.6 7.1-48.3 25.8-67s41.5-24.6 67-25.8c26.4-1.5 105.6-1.5 132 0c25.6 1.3 48.3 7.1 67 25.8s24.6 41.4 25.8 67c1.5 26.3 1.5 105.4 0 131.9c-1.3 25.6-7.1 48.3-25.8 67z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000" viewBox="0 0 1000 1000">
<defs>
<style>
.cls-1 {
stroke: #000;
stroke-width: 1px;
stroke-dasharray: 4 2;
fill-rule: evenodd;
}
</style>
</defs>
<path id="Tiktok_1" data-name="Tiktok 1" class="cls-1" d="M820.292,990H171.646C14.581,990,11,989.648,11,821.766V174.626c0-159.964-1.459-161.5,154.584-161.5C198.026,13.122,258.91,12,291.878,12h458.7c43.88,0,15.011.37,47.358,0.37C986.933,12.37,987,17.717,987,199.3V793.727C987,989.389,946.485,990,820.292,990ZM532.342,195.936q-3.031,3.924-6.062,7.851c7.912,32.725,2.02,81.845,2.02,118.885V621.007c-5.878,25.116-21.509,52.119-39.4,63.929-65.7,43.363-157.373-18.919-133.366-104.305,7.6-27.019,36.416-53.485,62.642-59.443,14.361-3.262,33.807,2.726,44.455-2.243,5.2-22.038,1.692-65.469,2.021-94.211-96.37-32.3-186.121,59-204.091,132.344-5.533,22.583-15.541,61.3-6.062,91.968,25.379,82.1,60.537,106.81,129.325,143.56,19.407,10.368,70.144,7.051,90.932,2.243,90.864-21.02,136.493-72.949,153.573-176.085q-0.505-106.538-1.01-213.1l5.051-6.729c11.848,0.933,19.225,11.11,27.28,16.823,16.459,11.676,54.457,22.153,79.818,22.432,2.394-19.282,6.375-99.811-1.011-117.764-15.162-6.86-34.949-4.067-50.517-12.337-19.277-10.241-42.6-26.041-55.57-43.741C626.435,256.259,598.6,199.14,597,198.179,583.942,190.284,549.838,195.687,532.342,195.936Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M64 32C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64H384c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64H64zm297.1 84L257.3 234.6 379.4 396H283.8L209 298.1 123.3 396H75.8l111-126.9L69.7 116h98l67.7 89.5L313.6 116h47.5zM323.3 367.6L153.4 142.9H125.1L296.9 367.6h26.3z"/></svg>

After

Width:  |  Height:  |  Size: 493 B

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="m23 12l-2.44-2.78l.34-3.68l-3.61-.82l-1.89-3.18L12 3L8.6 1.54L6.71 4.72l-3.61.81l.34 3.68L1 12l2.44 2.78l-.34 3.69l3.61.82l1.89 3.18L12 21l3.4 1.46l1.89-3.18l3.61-.82l-.34-3.68zm-13 5l-4-4l1.41-1.41L10 14.17l6.59-6.59L18 9z" />
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

View File

@@ -0,0 +1,24 @@
h1 {
@apply self-center my-10 text-6xl font-bold;
}
h2 {
@apply text-lg font-semibold font-sans;
}
.DocTitle {
font-size: 2rem;
text-align: center;
color: #a30e79;
}
.SubTitle {
font-size: 1.5rem;
text-align: center;
color: #a30e79;
@apply py-6;
}
.DocContainer{
@apply flex flex-col items-center justify-center max-w-screen-md px-5 pb-14 mx-auto;
}

View File

@@ -0,0 +1,49 @@
.banner-image {
margin: 0 auto;
width: 100%; /* Augmenter la largeur de l'image */
max-height: 40vh; /* Réduire légèrement la hauteur de l'image */
object-fit: cover;
object-position: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); /* Ajouter une ombre à l'image */
}
.banner-images-row {
display: flex;
justify-content: space-between;
gap: 0; /* Supprime l'espacement entre les éléments */
}
.banner-image-item-wrapper {
width: 76%; /* Définit la largeur du conteneur à 20% de la largeur de l'écran */
height: auto; /* Garde la hauteur d'origine */
}
.banner-image-item {
width: 100%; /* Définit la largeur de l'image à 100% du conteneur */
height: 300x; /* Hauteur fixe pour l'exemple, à ajuster selon vos besoins */
object-fit: cover; /* Coupe l'image pour remplir le conteneur tout en conservant les proportions */
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
background-color: white;
box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
padding: 10px;
border-radius: 5px;
display: none;
}
.banner-image-item:hover .dropdown-menu {
display: block;
}
.custom-container {
width: 40%;
}

View File

@@ -0,0 +1,112 @@
/* CSS pour ajuster la taille de l'image */
.img-small {
width: 70px;
height: 70px;
}
.img-Logo {
width: 200px;
height: 70px;
}
.text-custom {
color: #000000;
/* Couleur de texte spécifique */
}
/* CSS pour le texte du menu-left */
.menu-left a {
color: #000000;
/* Couleur du texte */
font-weight: bold;
/* Gras */
text-decoration: none;
/* Pas de soulignement */
font-size: 24px;
/* Taille de la police en pixels */
}
/* CSS pour le texte des liens du menu-center */
.menu-center {
display: flex;
justify-content: center;
/* Centrer les éléments horizontalement */
align-items: center;
/* Centrer les éléments verticalement */
flex: 1;
/* Utiliser tout l'espace disponible */
margin-right: 8%
}
/* CSS pour le texte du menu-right */
.menu-right a {
color: #e4e4e4;
/* Couleur du texte */
}
.bg-custom {
background-color: #ffffff;
/* Définissez la couleur de fond souhaitée */
}
.textLogo {
font-size: 35px;
/* Taille de la police en pixels */
}
.logo {
margin-right: 5px;
/* Réduire la marge entre le logo et le texte */
}
.profilePicture {
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
/* Ajouter une ombre à la photo */
border: 2px solid #a30e79;
/* Ajouter une bordure de 2px solide de couleur rouge (#f00) */
}
.bg-customdarker {
background-color: #ffffffa4;
/* Définissez la couleur de fond souhaitée */
}
.top-aligned-column {
display: flex;
justify-content: space-between;
}
.column {
flex: 1;
}
.center-column {
flex: 3;
/* La colonne centrale occupe 3 fois plus d'espace que les autres */
}
.colum-aligncenter {
text-align: center;
}
.menu-center a:nth-child(1):hover svg {
color: rgba(163, 14, 121, 1);
/* Changer la couleur en rouge au survol */
}
/* Pour le deuxième bouton */
.menu-center a:nth-child(2):hover svg {
color: rgba(163, 14, 121, 1);
/* Changer la couleur en bleu au survol */
}
/* Pour le troisième bouton */
.menu-center a:nth-child(3):hover svg {
color: rgba(163, 14, 121, 1);
/* Changer la couleur en vert au survol */
}

View File

@@ -0,0 +1,41 @@
.h1-tos {
font-size: 3rem;
font-weight: bold;
}
.h2-tos {
font-size: 2rem;
font-weight: bold;
margin-top: 5px;
margin-bottom: 5px;
}
.p-tos {
font-size: 1.1rem;
margin-bottom: 15px;
}
.card-member {
margin: 10px;
}
.card-content {
margin: 10px;
}
.member-name {
font-size: 1.5rem;
font-weight: bold;
}
.member-title {
font-size: 1rem;
margin-bottom: 15px;
}
.member-description {
hyphens: auto;
font-size: 0.8rem;
text-align: justify;
}

15
frontend/src/i18n.js Normal file
View File

@@ -0,0 +1,15 @@
import { createI18n } from 'vue-i18n';
import en from './locales/en.json';
import fr from './locales/fr.json';
const messages = {
en,
fr,
};
const i18n = createI18n({
legacy: false,
locale: 'fr',
fallbackLocale: 'en',
messages,
});
export default i18n;

View File

@@ -0,0 +1,68 @@
import {useI18n} from "vue-i18n";
export function time_ago(time) {
const time_date = new Date(time)
const now_date = new Date(Date.now())
const ago = now_date - time_date
return internal_time_ago(ago)
}
function internal_time_ago(time) {
switch (typeof time) {
case 'number':
break;
case 'string':
time = +new Date(time);
break;
case 'object':
if (time.constructor === Date) time = time.getTime();
break;
default:
time = +new Date();
}
const {t} = useI18n();
const time_formats = [
[60, t('time.seconds'), 1], // 60
[120, t('time.1minuteago'), t('time.1minutefromnow')], // 60*2
[3600, t('time.minutes'), 60], // 60*60, 60
[7200, t('time.1hourago'), t('time.1hourfromnow')], // 60*60*2
[86400, t('time.hours'), 3600], // 60*60*24, 60*60
[172800, t('time.yesterday'), t('time.tomorrow')], // 60*60*24*2
[604800, t('time.days'), 86400], // 60*60*24*7, 60*60*24
[1209600, t('time.lastweek'), t('time.nextweek')], // 60*60*24*7*4*2
[2419200, t('time.weeks'), 604800], // 60*60*24*7*4, 60*60*24*7
[4838400, t('time.lastmonth'), t('time.nextmonth')], // 60*60*24*7*4*2
[29030400, t('time.months'), 2419200], // 60*60*24*7*4*12, 60*60*24*7*4
[58060800, t('time.lastyear'), t('time.nextyear')], // 60*60*24*7*4*12*2
[2903040000, t('time.years'), 29030400], // 60*60*24*7*4*12*100, 60*60*24*7*4*12
[5806080000, t('time.lastcentury'), t('time.nextcentury')], // 60*60*24*7*4*12*100*2
[58060800000, t('time.centuries'), 2903040000] // 60*60*24*7*4*12*100*20, 60*60*24*7*4*12*100
];
let seconds = time / 1000
let token = t('time.ago')
let list_choice = 1
if (seconds === 0) {
return 'Just now'
}
if (seconds < 0) {
seconds = Math.abs(seconds);
token = 'from now';
list_choice = 2;
}
let i = 0, format;
while (format = time_formats[i++])
if (seconds < format[0]) {
if (typeof format[2] == 'string')
return format[list_choice];
else
return Math.floor(seconds / format[2]) + ' ' + format[1] + ' ' + token;
}
return time;
}

View File

@@ -0,0 +1,144 @@
{
"language": {
"language": "En"
},
"general": {
"yes": "yes",
"no": "no"
},
"banner": {
"subscription": "Subscriptions"
},
"footer": {
"allRightsReserved": "All rights reserved",
"helpandcontact": "Help and contact",
"faq": "FAQ",
"creatorguide": "Creator guide",
"termsandconditions": "Terms and conditions",
"contentpolicy": "Content policy",
"about": "About",
"pricing": "Pricing"
},
"sidebar": {
"subscriptionTitle": "Subscription",
"connection": "connection",
"Reduce": "Reduce"
},
"subscribebutton": {
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe"
},
"profilemenu": {
"manageyouraccount": "Manage your Hutopy account",
"creator": "Creator",
"user": "User"
},
"header": {
"myprofile": "My profile",
"wallet": "Wallet",
"Signout": "Sign out"
},
"message": {
"edit": "Edit",
"delete": "Delete",
"report": "Report",
"yourcomment": "Your comment..."
},
"contentCard": {
"commenttitle": "comments",
"edit": "Edit",
"delete": "delete",
"report": "report",
"deletecontenttitle": "Delete this content?",
"deeletecontentwarning": "Are you sure you want to delete the content?"
},
"time": {
"seconds": "seconds",
"1minuteago": "1 minute ago",
"1minutefromnow": "1 minute from now",
"minutes": "minutes",
"1hourago": "1 hour ago",
"1hourfromnow": "1 hour from now",
"hours": "hours",
"yesterday": "Yesterday",
"tomorrow": "Tomorrow",
"days": "days",
"lastweek": "Last week",
"nextweek": "Next week",
"weeks": "weeks",
"lastmonth": "Last month",
"nextmonth": "Next month",
"months": "months",
"lastyear": "Last year",
"nextyear": "Next year",
"years": "years",
"lastcentury": "Last century",
"nextcentury": "Next century",
"centuries": "centuries",
"ago": "ago"
},
"personnalinformation": {
"informations": "Informations",
"title": "Personal information",
"profilepicture": "Profile picture",
"fullname": "Name",
"firstname": "Firstname",
"lastname": "Lastname",
"alias": "Alias",
"dob": "Date of birth",
"gender": "Gender",
"contactdetails": "Contact details",
"email": "Email",
"phone": "Phone",
"addresses": "Addresses",
"home": "Home",
"work": "Work"
},
"creatorinfopage": {
"informations": "Informations",
"name": "Name",
"title": "Title",
"description": "Description",
"banner&profile": "Banner and profile picture",
"borderpicturecolor": "Profile picture border",
"menucolor": "Menu color",
"pageinformation": "Your page information",
"socialnetwork": "Social Network"
},
"accountmanagement": {
"accounts": "Account",
"accountmanagement": "Account Management",
"pageid": "Page ID",
"addaccount": "Add Account",
"authoritygranted": "Granted Permissions"
},
"security": {
"title": "Security",
"howtoconnect": "How to connect to Hutopy",
"password": "Password",
"recoverybyemail": "Recovery by Email",
"recoverybymobile": "Recovery by Mobile"
},
"isupportbtn": {
"isupport": "I support",
"amount": "amount",
"message": "Message (optional)",
"send": "send"
},
"paymentConfirmation": {
"success": {
"title": "Payment completed",
"message": "Your payment was successful. Thank you for supporting ",
"usernameDefault": "this user.",
"receipt": "A receipt has been sent to your email address.",
"continue": "Continue"
},
"failure": {
"title": "Payment cancelled",
"message": "The payment was cancelled. If you did not intend to cancel, please try again.",
"thanks": "Thank you for supporting",
"tryAgain": "Try again",
"return": "Return to the profile of "
}
}
}

View File

@@ -0,0 +1,144 @@
{
"language": {
"language": "Fr"
},
"general": {
"yes": "oui",
"no": "non"
},
"banner": {
"subscription": "Abonnés"
},
"footer": {
"allRightsReserved": "Tout droits réservés",
"helpandcontact": "Aide & Contact",
"faq": "FAQ",
"creatorguide": "Guide pour les créateurs",
"termsandconditions": "Termes et conditions",
"contentpolicy": "Politique de contenu",
"about": "À propos",
"pricing": "Frais"
},
"sidebar": {
"subscriptionTitle": "Abonnements",
"connection": "connexion",
"Reduce": "réduire"
},
"subscribebutton": {
"subscribe": "S'abonner",
"unsubscribe": "Se désabonner"
},
"profilemenu": {
"manageyouraccount": "Gérer votre compte Hutopy",
"creator": "Créateur",
"user": "utilisateur"
},
"header": {
"myprofile": "Mon profil",
"wallet": "PorteFeuille",
"Signout": "se déconnecter"
},
"message": {
"edit": "Modifier",
"delete": "Effacer",
"report": "Signaler",
"yourcomment": "Votre commentaire..."
},
"contentCard": {
"commenttitle": "commentaires",
"edit": "Modifier",
"delete": "Effacer",
"report": "signaler",
"deletecontenttitle": " Supprimer ce contenu?",
"deeletecontentwarning": "Êtes-vous sûr de vouloir supprimer le contenu?"
},
"time": {
"seconds": "secondes",
"1minuteago": "Il y a 1 minute",
"1minutefromnow": "dans 1 minute",
"minutes": "minutes",
"1hourago": "Il y a 1 heure",
"1hourfromnow": "dans 1 heure",
"hours": "heures",
"yesterday": "hier",
"tomorrow": "demain",
"days": "jours",
"lastweek": "La semaine dernière.",
"nextweek": "La semaine prochaine",
"weeks": "semaines",
"lastmonth": "Le mois dernier",
"nextmonth": "Le mois prochain",
"months": "mois",
"lastyear": "L'année dernière",
"nextyear": "L'année prochaine",
"years": "années",
"lastcentury": "Le siècle dernier",
"nextcentury": "Le siècle prochain",
"centuries": "siècles",
"ago": ""
},
"personnalinformation": {
"informations": "Informations",
"title": "Informations personnelles",
"profilepicture": "Photo de profil",
"fullname": "Nom",
"firstname": "Prénom",
"lastname": "Nom",
"alias": "Pseudonyme",
"dob": "Date de naissance",
"gender": "Genre",
"contactdetails": "Coordonnées",
"email": "Email",
"phone": "Téléphone",
"addresses": "Adresses",
"home": "Domicile",
"work": "Travail"
},
"creatorinfopage": {
"informations": "Informations",
"name": "Nom",
"title": "Titre",
"description": "Description",
"banner&profile": "Bannière et photo de profil",
"borderpicturecolor": "Bordure de la photo de profil",
"menucolor": "Couleur des menus",
"pageinformation": "Informations de votre page",
"socialnetwork": "Réseaux sociaux"
},
"accountmanagement": {
"accounts": "comptes",
"accountmanagement": "Gestion des comptes",
"pageid": "ID de la page",
"addaccount": "Ajouter un compte",
"authoritygranted": "Autorisations accordées"
},
"security": {
"title": "Sécurité",
"howtoconnect": "Comment vous connecter à Hutopy",
"password": "Mot de passe",
"recoverybyemail": "Récupération par email",
"recoverybymobile": "Récupération par mobile"
},
"isupportbtn": {
"isupport": "Je soutiens",
"amount": "Montant",
"message": "Message (facultatif)",
"send": "Envoyez"
},
"paymentConfirmation": {
"success": {
"title": "Paiement complété",
"message": "Votre paiement a été effectué avec succès. Merci de soutenir ",
"usernameDefault": "cet utilisateur.",
"receipt": "Un reçu a été envoyé à votre adresse courriel.",
"continue": "Continuer"
},
"failure": {
"title": "Paiement annulé",
"message": "Le paiement a été annulé. Si vous n'aviez pas l'intention d'annuler, veuillez réessayer.",
"thanks": "Merci de supporter",
"tryAgain": "Réessayer",
"return": "Retour au profil de "
}
}
}

41
frontend/src/main.js Normal file
View File

@@ -0,0 +1,41 @@
import {createApp} from 'vue'
import App from './App.vue'
import router from './router/router.js'
import './assets/main.css'
import {createPinia} from 'pinia'
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
import {createVuetify} from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import vueGoogleOauth from 'vue3-google-login'
import {useSubscriptionStore} from "@/stores/subscriptionStore.js";
import {useAuthStore} from "@/stores/authStore.js";
import i18n from './i18n.js';
import {useUserProfileStore} from "@/stores/userProfileStore.js";
import {useCreatorProfileStore} from "@/stores/creatorProfileStore.js";
const vuetify = createVuetify({
components,
directives
});
const app = createApp(App)
.use(createPinia())
.use(vuetify)
.use(router)
.use(vueGoogleOauth, {
clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID,
})
.use(i18n)
// Make $t globally available
app.config.globalProperties.$t = i18n.global.t;
// this force the creation and initialization of the stores
useSubscriptionStore()
useAuthStore()
useUserProfileStore()
useCreatorProfileStore()
app.mount('#app');

View File

@@ -0,0 +1,23 @@
export default class UserTransactionsModel
{
amount = "";
currency = "";
tipMessage = "";
created = "";
static createFromApiResult(apiResult){
const userTransactionModel = Object.assign(new UserTransactionsModel(), apiResult)
const date = new Date(userTransactionModel.created);
const options = {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'America/Montreal'
};
userTransactionModel.created = new Intl.DateTimeFormat('fr-CA', options).format(date);
return userTransactionModel;
}
}

View File

@@ -0,0 +1,24 @@
import axios from "axios"
import {useAuthStore} from "@/stores/authStore.js"
export function useClient() {
if (!import.meta.env.VITE_API_URL) throw new Error("VITE_API_URL is not provided")
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10000,
});
const authStore = useAuthStore()
const requestInterceptor = (config) => {
if (authStore.isAuthenticated) {
config.headers["Authorization"] = `Bearer ${authStore.accessToken}`
}
return config
}
api.interceptors.request.use(requestInterceptor);
return api;
}

View File

@@ -0,0 +1,185 @@
import { useAuthStore } from '@/stores/authStore.js';
import CTA01 from '@/views/CTA01.vue';
import PaymentFailed from '@/views/PaymentFailed.vue';
import CreatorList from '@/views/browser/CreatorList.vue';
import ContentEditorPage from '@/views/contents/ContentEditorPage.vue';
import ContentPage from '@/views/contents/ContentPage.vue';
import PostContent from '@/views/contents/PostContent.vue';
import CreatorContent from '@/views/creators/CreatorContent.vue';
import CreatorHome from '@/views/creators/CreatorHome.vue';
import CreatorLayout from '@/views/creators/CreatorLayout.vue';
import ExclusiveContentCard from '@/views/creators/ExclusiveContentCard.vue';
import SubscriptionMenu from '@/views/creators/SubscriptionMenu.vue';
import About from '@/views/documentation/About.vue';
import ContentPolicy from '@/views/documentation/ContentPolicy.vue';
import CreatorGuide from '@/views/documentation/CreatorGuide.vue';
import DocumentationHome from '@/views/documentation/DocumentationHome.vue';
import DocumentationLayout from '@/views/documentation/DocumentationLayout.vue';
import FAQ from '@/views/documentation/FAQ.vue';
import HelpAndContact from '@/views/documentation/HelpAndContact.vue';
import Pricing from '@/views/documentation/Pricing.vue';
import TermsAndConditions from '@/views/documentation/TermsAndConditions.vue';
import ProfilePage from '@/views/profile/ProfilePage.vue';
import { createRouter, createWebHistory } from 'vue-router';
import LoginView from '../views/LoginView.vue';
import PaymentCompleted from '../views/PaymentCompleted.vue';
import Home from '../views/main/Home.vue';
import Wallet from '../views/main/Wallet.vue';
import CreateCreator from "@/views/profile/creators/CreateCreator.vue";
const routes = [
{
path: '/cta01',
component: CTA01,
},
{
path: '/landing',
name: 'landing',
component: Home,
},
{
path: '/',
redirect: { name: 'landing' },
},
{
path: '/browse',
component: CreatorList,
redirect: { name: 'landing' }, // TODO remove this line when the page is ready
},
{
path: '/content/editor',
component: ContentEditorPage,
},
{
path: '/content/:contentId',
component: ContentPage,
},
{
path: '/@:creator',
component: CreatorLayout,
name: 'creator',
children: [
{
path: '',
component: CreatorHome,
},
{
path: 'content',
component: CreatorContent,
},
{
path: 'subscription',
component: SubscriptionMenu,
redirect: { name: 'creator' }, // TODO remove this line when the page is ready
},
{
path: 'exclusivecontentcard',
component: ExclusiveContentCard,
},
],
},
{
path: '/documents',
component: DocumentationLayout,
children: [
{
path: '',
component: DocumentationHome,
redirect: { name: 'about' }, // TODO remove this line when the page is ready
},
{
path: 'helpandcontact',
name: 'helpandcontact',
component: HelpAndContact,
},
{
path: 'termsandconditions',
name: 'termsandconditions',
component: TermsAndConditions,
},
{
path: 'contentpolicy',
name: 'contentpolicy',
component: ContentPolicy,
},
{
path: 'faq',
name: 'FAQ',
component: FAQ,
},
{
path: 'guideforcreators',
name: 'guideforcreators',
component: CreatorGuide,
},
{
path: 'about',
name: 'about',
component: About,
},
{
path: 'pricing',
name: 'pricing',
component: Pricing,
},
],
},
{
path: '/content/post',
component: PostContent,
redirect: { name: 'landing' }, // TODO remove this line when the page is ready
},
{
path: '/login',
name: 'login',
component: LoginView,
meta: { notAuthenticated: true },
},
{
path: '/paymentcompleted/:creatorId',
name: 'PaymentCompleted',
component: PaymentCompleted,
},
{
path: '/paymentfailed/:creatorId',
name: 'PaymentFailed',
component: PaymentFailed,
},
{
path: '/wallet',
name: 'wallet',
component: Wallet,
meta: { requiresAuth: true },
},
{
path: '/profile',
name: 'profile',
component: ProfilePage,
meta: { requiresAuth: true },
},
{
path: '/create-creator',
name: 'create-creator',
component: CreateCreator,
meta: { requiresAuth: true },
},
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
// Navigation guards
router.beforeEach((to, from, next) => {
const authStore = useAuthStore();
if (to.matched.some((record) => record.meta.requiresAuth)) {
if (!authStore.isAuthenticated) next({ name: 'login' });
} else if (to.matched.some((record) => record.meta.notAuthenticated)) {
if (authStore.isAuthenticated) next({ name: 'landing' });
}
next();
});
export default router;

View File

@@ -0,0 +1,102 @@
import {defineStore} from 'pinia';
import {computed, ref} from "vue";
import {useRouter} from "vue-router";
import {useClient} from "@/plugins/api.js";
import {useSessionStorage} from "@vueuse/core";
import {jwtDecode} from "jwt-decode";
function getClaimsFromToken(token) {
try {
return jwtDecode(token);
} catch (error) {
console.error('Invalid token:', error);
return null;
}
}
export const useAuthStore = defineStore(
'auth',
() => {
const clientApi = useClient()
const router = useRouter()
const accessToken = useSessionStorage('auth-accessToken', undefined)
const refreshToken = useSessionStorage('auth-refreshToken', undefined)
const isAuthenticated = computed(() => !!accessToken.value)
const userId = computed(() => {
const claims = getClaimsFromToken(accessToken.value)
return claims.sub;
})
function updateTokens(data) {
accessToken.value = data.accessToken
refreshToken.value = data.refreshToken
}
function cleanTokens() {
updateTokens({
accessToken: undefined,
refreshToken: undefined,
})
}
async function logout() {
cleanTokens()
await router.push('/')
}
async function login(email, password) {
try {
const response = await clientApi.post(
'api/users/login',
{
email: email,
password: password
})
updateTokens(response.data)
return true
} catch (error) {
console.error(error)
cleanTokens()
return false
}
}
async function loginWithGoogle(accessToken) {
try {
const response = await clientApi.post(
'api/users/login-with-google',
{
token: accessToken
})
updateTokens(response.data)
return true
} catch (error) {
console.error(error)
cleanTokens()
return false
}
}
async function refresh() {
try {
const response = await clientApi.post(
'api/users/refresh',
{
refreshToken: refreshToken
});
updateTokens({
accessToken: response.accessToken,
refreshToken: refreshToken
})
} catch (error) {
console.error(error)
cleanTokens()
}
}
return {accessToken, refreshToken, isAuthenticated, userId, login, loginWithGoogle, logout}
})

View File

@@ -0,0 +1,75 @@
import {defineStore} from 'pinia'
import {useClient} from "@/plugins/api.js";
import {useSessionStorage} from "@vueuse/core";
import {ref, watch} from "vue";
import {useRoute} from "vue-router";
export const useBrandingStore = defineStore(
'branding',
() => {
const currentBrand = ref(undefined)
const loading = ref(false)
const value = useSessionStorage(
'branding',
{},
{writeDefaults: false})
const defaultColors = {
"background": "#f4f4f4",
"error": "#f4f4f4",
"primary": "#f4f4f4",
"secondary": "#f4f4f4",
"surface": "#f4f4f4",
"onBackground": "#000",
"onError": "#000",
"onPrimary": "#000",
"onSecondary": "#000",
"onSurface": "#000",
}
const colors = ref(defaultColors)
const presentationInfos = ref([])
const route = useRoute()
watch(
() => route.params.creator,
async (newCreator, oldCreator) => {
loading.value = true
if (newCreator !== oldCreator) {
if (newCreator !== undefined) {
value.value = await fetchCreatorData(newCreator)
currentBrand.value = newCreator
colors.value = value.value.colors
presentationInfos.value = value.value.presentationInfos
} else {
value.value = {}
currentBrand.value = undefined
colors.value = defaultColors
presentationInfos.value = []
}
}
loading.value = false
}
)
const fetchCreatorData = async (creatorAlias) => {
try {
const client = useClient()
const response = await client.get(`/api/creators/@${creatorAlias}`)
return response.data
} catch (error) {
console.error(`Error fetching content: ${error}`)
}
}
return {
currentBrand,
value,
colors,
loading,
presentationInfos
}
})

View File

@@ -0,0 +1,67 @@
import { useClient } from '@/plugins/api.js';
import { useAuthStore } from '@/stores/authStore.js';
import { useSessionStorage } from '@vueuse/core';
import { defineStore } from 'pinia';
import { computed, watch } from 'vue';
import { useRouter } from 'vue-router';
export const useCreatorProfileStore = defineStore('creator-profile', () => {
const router = useRouter();
const authStore = useAuthStore();
watch(
() => authStore.isAuthenticated,
async (newValue) => {
if (newValue) {
await fetchCurrentCreatorProfile();
if (value.value === undefined) {
await router.push('/');
} else {
await router.push(`/@${value.value.name}`);
}
} else {
value.value = undefined;
}
}
);
const value = useSessionStorage(
'creator-profile',
{},
{ writeDefaults: false }
);
const hasCreator = computed(
() => value.value && Object.getOwnPropertyNames(value.value).length >= 1
);
const client = useClient();
async function fetchCurrentCreatorProfile() {
try {
const creatorResponse = await client.get(`/api/creators/profile`);
value.value = creatorResponse.data;
// TODO: no cache-busting ???
} catch (error) {
value.value = undefined;
}
}
async function ConfigureStripeAccount() {
try {
await client.post(`/api/membership/stripe-account`);
return true;
} catch (error) {
return false;
}
}
return {
creator: value,
hasCreator,
fetchCurrentCreatorProfile,
ConfigureStripeAccount,
};
});

View File

@@ -0,0 +1,24 @@
import { defineStore } from 'pinia'
import {ref} from "vue";
import {useClient} from "@/plugins/api.js";
export const useMessageStore = defineStore('message', () => {
const messageCount = ref(0);
const trackedSubject = ref('');
async function fetchMessageCount(subjectId){
const client = useClient();
try {
let uri = `/api/message-count/${subjectId}`;
const response = await client.get(uri);
messageCount.value = response.data.count;
trackedSubject.value = subjectId;
} catch (error) {
console.error("Failed to fetch messages", error);
}
return messageCount.value;
}
return { messageCount, trackedSubject, fetchMessageCount }
})

View File

@@ -0,0 +1,16 @@
// src/stores/sideBarStore.js
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
export const useSideBarStore = defineStore('sideBar', () => {
const isOpen = ref(true); // par défaut, le menu est ouvert
const toggle = () => {
isOpen.value = !isOpen.value;
};
// Classe de largeur dynamique pour le contenu principal
const sidebarWidth = computed(() => (isOpen.value ? 'ml-64' : 'ml-16'));
return { isOpen, toggle, sidebarWidth };
});

View File

@@ -0,0 +1,48 @@
import {defineStore} from "pinia";
import {useSessionStorage} from "@vueuse/core";
import {useClient} from "@/plugins/api.js";
import {useAuthStore} from "@/stores/authStore.js";
import {watch, onMounted} from "vue";
export const useSubscriptionStore = defineStore(
'subscription',
() => {
const authStore = useAuthStore()
watch(
() => authStore.isAuthenticated,
async (newValue) => {
if (newValue) {
await loadSubscriptions()
} else {
subscriptions.value = {}
}
})
const subscriptions = useSessionStorage(
'subscription-subscriptions',
{})
function isSubscribeTo(creatorId) {
return !!subscriptions.value[creatorId];
}
async function loadSubscriptions() {
try {
const client = useClient()
const response = await client.get(`/api/membership/active`);
subscriptions.value = response.data.reduce(
(acc, sub) => {
acc[sub.creatorId] = sub;
return acc;
},
{});
} catch (error) {
console.error("Error loading subscriptions:", error);
}
}
return {subscriptions, isSubscribeTo}
});

View File

@@ -0,0 +1,178 @@
import {computed, watch} from 'vue'
import {defineStore} from 'pinia'
import {useAuthStore} from "@/stores/authStore.js";
import {useClient} from "@/plugins/api.js";
import {useSessionStorage} from "@vueuse/core";
export const useUserProfileStore = defineStore(
'user-profile',
() => {
const authStore = useAuthStore()
const authWatcher = watch(
() => authStore.isAuthenticated,
async (newValue) => {
if (newValue) {
await fetchCurrentUserProfile()
} else {
value.value = undefined
}
})
const value = useSessionStorage(
'user-profile',
{},
{writeDefaults: false})
const fullname = computed(() => {
if (value.value) {
const {firstname, lastname} = value.value;
if (firstname && lastname) {
return `${lastname}, ${firstname}`;
} else if (firstname) {
return firstname;
} else if (lastname) {
return lastname;
}
}
return 'n/a';
})
const alias = computed(() => {
if (value.value) {
return value.value.alias || `${value.value.firstname || ''} ${value.value.lastname || ''}`.trim() || 'Anonyme'
}
return 'Anonyme';
})
const portraitUrl = computed(() => {
return value.value && value.value.portraitUrl
? value.value.portraitUrl
: '/images/usersmedia/anonyme/profilepictures/profileAnonymeSquare.png'
})
async function fetchCurrentUserProfile() {
try {
const client = useClient()
const userResponse = await client.get("/api/users/profile");
value.value = userResponse.data
// Cache-busting only if portraitUrl exists
if (value.value.portraitUrl) {
value.value.portraitUrl = `${value.value.portraitUrl}?${Date.now()}`;
}
} catch (error) {
value.value = undefined;
}
}
async function changeFullname(firstname, lastname) {
try {
await client.post(
`/api/users/fullname`,
{
firstname: firstname,
lastname: lastname
})
value.value.firstname = firstname;
value.value.lastname = lastname;
} catch (error) {
console.error(error)
}
}
async function changeAlias(alias) {
try {
await client.post(
`/api/users/alias`,
{
alias: alias
})
value.value.alias = alias;
} catch (error) {
console.error(error)
}
}
async function changeBirthday(birthdate) {
try {
await client.post(
`/api/users/birthdate`,
{
birthdate: birthdate
})
value.value.birthDate = birthdate;
} catch (error) {
console.error(error)
}
}
async function changePhone(phoneNumber) {
try {
await client.post(
`/api/users/phone`,
{
phoneNumber: phoneNumber
})
value.value.phoneNumber = phoneNumber;
} catch (error) {
console.error(error)
}
}
async function changeEmail(email) {
try {
await client.post(
`/api/users/email`,
{
email: email
})
value.value.email = email;
} catch (error) {
console.error(error)
}
}
async function changeAddress(address) {
try {
await client.post(
`/api/users/address`,
{
address: address
})
value.value.address = address;
} catch (error) {
console.error(error)
}
}
async function changePortrait(selectedFile) {
try {
const formData = new FormData();
formData.append('file', selectedFile)
const response = await client.post(
`/api/users/portrait`,
formData)
value.value.portraitUrl = `${response.data.blobUrl}?${Date.now()}` // the Date.now() is for cache-busting
} catch (error) {
console.error(error)
}
}
return {
user: value,
alias,
fullname,
portraitUrl,
changeFullname,
changeAlias,
changeBirthday,
changePhone,
changeEmail,
changeAddress,
changePortrait
}
})

View File

@@ -0,0 +1,134 @@
<template>
<!-- lg et xl-->
<div class="hidden lg:block xl:block background-container-lg">
<img src="/images/hutopymedia/banners/hutopy.png" alt="Image bgwhite" class="block mx-auto max-w-[500px] mt-15"/>
<div class="flex flex-row space-x-3.5 justify-center py-15">
<div class="flex flex-column max-w-[500px]">
<img src="/images/hutopymedia/others/ctaappdemo.png" alt="Image bgwhite" class="max-w-[500px]"/>
</div>
<div class="flex flex-column space-y-16 max-w-[475px] ma-12 text-justify">
<h1 class="font-bold text-4xl font-serif">Monétisez votre contenu à sa vraie valeur.</h1>
<p>Nous créons une plateforme qui vous rémunère équitablement et vous aide à évoluer comme créateur. Rejoignez-nous et participez au prototype !</p>
<div class="flex flex-col items-center">
<!-- Boîte pour courriel et bouton Participez -->
<div class="flex items-center space-x-2 w-full">
<v-text-field
v-model="email"
label="Votre courriel"
variant="outlined"
type="email"
placeholder="Votre courriel"
class="w-full mt-6"
/>
<v-btn class="text-white " height="60px" style="border-radius: 8px; background-color: #9F2E8D;">
Participez
</v-btn>
</div>
</div>
</div>
</div>
<div class=" border-t-[4px] border-b-[4px] p-8 flex justify-center text-3xl text-white" style="background-color: #9F2E8D; border-color: #23393B;">
<div class="flex flex-row space-x-[250px]">
<a href="https://www.facebook.com/Hutopy" target="_blank" aria-label="Facebook" class="hover:scale-125 transition-transform duration-300">
<v-icon>mdi-facebook</v-icon>
</a>
<a href="https://www.instagram.com/hutopy.inc/" target="_blank" aria-label="Instagram" class="hover:scale-125 transition-transform duration-300">
<v-icon>mdi-instagram</v-icon>
</a>
<a href="https://x.com/Hutopyinc" target="_blank" aria-label="X" class="hover:scale-125 transition-transform duration-300">
<v-icon>mdi-twitter</v-icon>
</a>
</div>
</div>
</div>
<!-- md et plus petit-->
<div class="background-container-md block lg:hidden xl:hidden">
<img src="/images/hutopymedia/banners/hutopy.png" alt="Image bgwhite" class="block mx-auto max-w-[400px] py-10"/>
<div class="max-w-[400px] mx-auto py-5">
<img src="/images/hutopymedia/others/ctaappdemo.png" alt="Image bgwhite"/>
</div>
<div class="text-justify px-10">
<h1 class="font-bold text-3xl font-serif hyphenated-text mb-10">Monétisez votre contenu à sa vraie valeur.</h1>
<v-text-field
v-model="email"
label="Votre courriel"
variant="outlined"
type="email"
placeholder="Votre courriel"
class="w-full"
density="compact"
/>
<v-btn class="text-white w-100" height="100px" style=" border-radius: 8px; background-color: #9F2E8D; font-size: 24px;">
Participez
</v-btn>
<p class="py-15">Nous créons une plateforme qui vous rémunère équitablement et vous aide à évoluer comme créateur. Rejoignez-nous et participez au prototype !</p>
</div>
<div class=" border-t-[4px] p-8 flex justify-center text-3xl text-white" style="background-color: #9F2E8D; border-color: #23393B;">
<div class="flex flex-row space-x-[100px]">
<a href="https://www.facebook.com/Hutopy" target="_blank" aria-label="Facebook" class="hover:scale-125 transition-transform duration-300">
<v-icon>mdi-facebook</v-icon>
</a>
<a href="https://www.instagram.com/hutopy.inc/" target="_blank" aria-label="Instagram" class="hover:scale-125 transition-transform duration-300">
<v-icon>mdi-instagram</v-icon>
</a>
<a href="https://x.com/Hutopyinc" target="_blank" aria-label="X" class="hover:scale-125 transition-transform duration-300">
<v-icon>mdi-twitter</v-icon>
</a>
</div>
</div>
</div>
</template>
<style scoped>
.background-container-lg {
width: 100%;
height: 100%;
background-image: url('/images/hutopymedia/others/ctabgwhite.png');
background-size: cover;
background-position: center;
}
.background-container-md {
width: 100%;
height: 100%;
max-height: 1600px;
background-image: url('/images/hutopymedia/others/ctabgwhite.png');
background-size: cover;
background-position: center;
}
.hyphenated-text {
text-align: justify;
hyphens: auto;
}
</style>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div v-if="isMobileView" class="flex flex-col items-center justify-center min-h-screen bg-gray-100 p-6 text-center">
<!-- Image -->
<img src="/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png" alt="Image" class="w-64 h-64 rounded-full mb-4 border" />
<!-- Message -->
<div class="text-lg text-gray-700 mt-8">
<p class="font-semibold mb-2">Pour vous connecter et modifier votre page, veuillez utiliser un appareil avec un écran plus large, comme un ordinateur.</p>
<p>Pour le moment, l'expérience sur téléphone n'est pas encore complétée.</p>
<p class="mt-4 font-bold">Désolé de l'inconvénient.</p>
</div>
</div>
<div>
<div class="flex items-start justify-center py-2">
<div class="max-w-[600px] mt-[10%] ">
<img class="rounded-2xl"
src="/images/hutopymedia/loginpage/loginhutopy.png"
alt="hutopy login">
<login-form></login-form>
</div>
</div>
</div>
</template>
<script setup>
import LoginForm from "@/views/main/LoginForm.vue";
import { useDisplay } from "vuetify";
import {ref, watch} from "vue";
const { smAndDown } = useDisplay();
const isMobileView = ref(smAndDown.value);
watch(smAndDown, (newVal) => {
isMobileView.value = newVal;
});
</script>

View File

@@ -0,0 +1,42 @@
<script setup>
import LoginForm from "@/views/main/LoginForm.vue";
const isOpen = defineModel();
const props = defineProps({
message: {
type: String,
required: true,
},
});
const handleSuccess = () => {
console.log('handleSuccess triggered');
isOpen.value = false;
}
const handleFailure = () => {
console.error('Login failed');
}
const closeModal = async () => {
console.log('closeModal triggered');
isOpen.value = false;
}
</script>
<template>
<v-dialog v-model="isOpen" max-width="400">
<v-card>
<div class="flex flex-col items-center">
<v-img src="/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png" class="w-50"></v-img>
<LoginForm :onSuccess="handleSuccess" :onFailure="handleFailure"></LoginForm>
<v-card-text>{{ message }}</v-card-text>
</div>
<v-card-actions>
<v-btn color="primary" text @click="closeModal">Fermer</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

View File

@@ -0,0 +1,118 @@
<template>
<v-container class="py-10">
<v-row class="d-flex flex-column align-center">
<v-col cols="10">
<v-card
class="elevation-3"
style="background-color: white; border-radius: 12px"
>
<!-- Title Section -->
<v-card-title class="text-center text-h4 font-weight-bold mb-4">
{{ $t('paymentConfirmation.success.title') }}
</v-card-title>
<!-- Check Icon -->
<v-card-text class="text-center mb-4">
<v-icon size="120" color="success">mdi-check-circle</v-icon>
</v-card-text>
<!-- Thank You Message -->
<v-card-text class="text-center mb-4">
<p class="text-h6">
{{ $t('paymentConfirmation.success.message') }}
<span class="text-h5 font-weight-bold" v-if="creatorUserName">{{
creatorUserName
}}</span>
<span class="text-h6" v-else>{{
$t('paymentConfirmation.success.usernameDefault')
}}</span>
</p>
</v-card-text>
<!-- Email Input and Receipt Button -->
<v-card-text class="text-center mb-4">
<p class="text-h6">
{{ $t('paymentConfirmation.success.receipt') }}
</p>
</v-card-text>
<!-- Continue Button -->
<v-card-actions class="justify-center">
<v-btn
color="primary"
class="text-white px-5 py-3"
@click="router.push({ path: `/@${creatorUserName}` })"
>
{{ $t('paymentConfirmation.success.continue') }}
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<!-- Error Snackbar -->
<v-snackbar v-model="errorSnackBar" color="red darken-1">
Aucun reçu trouvé pour cet email.
<template v-slot:actions>
<v-btn color="white" text @click="errorSnackBar = false">Fermer</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { useClient } from '@/plugins/api.js';
import { onBeforeMount, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const router = useRouter();
const route = useRoute();
const client = useClient();
const creatorId = route.params.creatorId;
const creatorUserName = ref('');
const email = ref('');
const errorSnackBar = ref(false);
onBeforeMount(async () => {
try {
const response = await client.get(`/api/creators/${creatorId}`);
creatorUserName.value = response.data.name;
} catch (error) {
console.error('Failed to fetch creator data:', error);
}
});
async function getReceipt() {
try {
const response = await client.get(
`/api/Stripe/GetMyLastReceipt?CreatorId=${creatorId}&Email=${email.value}`
);
const receiptUrl = response.data.receiptUrl;
if (!receiptUrl) {
errorSnackBar.value = true;
} else {
window.open(receiptUrl, '_blank');
}
} catch (error) {
console.error('Failed to fetch receipt:', error);
errorSnackBar.value = true;
}
}
</script>
<style scoped>
.v-container {
max-width: 800px;
margin: auto;
}
.v-card {
padding: 24px;
}
.v-btn {
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<v-container class="py-10">
<v-row class="d-flex flex-column align-center">
<v-col cols="10">
<v-card
class="elevation-3"
style="background-color: white; border-radius: 12px"
>
<!-- Title Section -->
<v-card-title
class="text-center text-h4 font-weight-bold mb-4 text-danger"
>
{{ $t('paymentConfirmation.failure.title') }}
</v-card-title>
<!-- Cancel Icon -->
<v-card-text class="text-center mb-4">
<v-icon size="120" color="error">mdi-close-circle</v-icon>
</v-card-text>
<!-- Message -->
<v-card-text class="text-center mb-4">
<p class="text-h6">
{{ $t('paymentConfirmation.failure.message') }}
</p>
<p class="text-h5 font-weight-bold">
{{ $t('paymentConfirmation.failure.thanks') }}
{{ creatorUserName }}
</p>
</v-card-text>
<!-- Back Button -->
<v-card-actions class="justify-center">
<v-btn
color="primary"
class="text-white px-5 py-3"
@click="router.push({ path: `/@${creatorUserName}` })"
>
{{ $t('paymentConfirmation.failure.return') }}
{{ creatorUserName }}
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script setup>
import { useClient } from '@/plugins/api.js';
import { onBeforeMount, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const router = useRouter();
const route = useRoute();
const client = useClient();
const creatorId = route.params.creatorId;
const creatorUserName = ref('');
onBeforeMount(async () => {
try {
const response = await client.get(`/api/creators/${creatorId}`);
creatorUserName.value = response.data.name;
} catch (error) {
console.error('Failed to fetch creator data:', error);
}
});
</script>
<style scoped>
.v-container {
max-width: 800px;
margin: auto;
}
.v-card {
padding: 24px;
}
.v-btn {
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<v-container>
<v-row>
<v-text-field label="Message (facultatif)" v-model="tipMessage"
style="border-radius: 10px; margin-top: 10px; margin-bottom: 10px; color: #a30e79; background-color: #f4f4f4">
</v-text-field>
</v-row>
<v-row>
<v-text-field label="Montant ($)" v-model="price"
style="border-radius: 10px; margin-bottom: 10px; color: #a30e79; background-color: #f4f4f4">
</v-text-field>
</v-row>
<v-row justify="center">
<v-btn @click="goPay()"
style="margin-bottom: 10px; width: 200px; background-color: #6b0065; color: white; font-weight: bold;">
<v-icon left style="margin-right: 10px;">
mdi-gift
</v-icon>
Envoyez
</v-btn>
</v-row>
<v-dialog v-model="isPaymentDialogActive" max-width="720" persistent>
<template v-slot:default>
<v-card>
<div id="checkout">
<!-- Checkout will insert the payment form here -->
</div>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn block class="ma-auto" style="width: 200px;" text="Annuler" @click="closeDialog()"></v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</v-container>
</template>
<script setup>
import { useClient } from '@/plugins/api.js';
import { loadStripe } from '@stripe/stripe-js';
import { onMounted, ref } from "vue";
const props = defineProps(['creatorId'])
let stripe = null;
const client = useClient();
const price = ref(0);
const tipMessage = ref("");
const isPaymentDialogActive = ref(false);
var checkout;
onMounted(async () => {
stripe = await loadStripe(import.meta.env.VITE_STRIPE_API_KEY);
})
const fetchClientSecret = async () => {
const clientSecret = await createCheckoutSession();
return clientSecret;
};
async function createCheckoutSession() {
let clientSecret = await client.post('/api/Stripe', {
amount: (price.value * 100),
tipMessage: tipMessage.value,
creatorId: props.creatorId
});
let secret = clientSecret["data"];
return secret;
}
function closeDialog() {
isPaymentDialogActive.value = false;
checkout.destroy();
}
async function goPay() {
isPaymentDialogActive.value = true;
checkout = await stripe.initEmbeddedCheckout({
fetchClientSecret,
});
await checkout.mount('#checkout');
}
</script>

View File

@@ -0,0 +1,21 @@
<template>
<v-card class="shadow-lg rounded-lg overflow-hidden max-w-sm">
<v-img :src="creator.imageUrl" class="w-full h-48 object-cover"></v-img>
<v-card-title class="text-lg font-bold">{{ creator.name }}</v-card-title>
<v-card-subtitle class="text-sm text-gray-500">{{ creator.title }}</v-card-subtitle>
<v-card-text class="text-base text-gray-700">{{ creator.description }}</v-card-text>
</v-card>
</template>
<script setup>
defineProps({
creator: {
type: Object,
required: true,
validator: (profile) => {
return 'image' in profile && 'name' in profile && 'title' in profile && 'description' in profile;
}
}
});
</script>

View File

@@ -0,0 +1,32 @@
<template>
<div>
<v-img max-height="375"
src="images/usersmedia/HutopyProfile/banners/banner01.png"
cover>
</v-img>
<div class="text-5xl font-semibold text-center py-10">
CRÉATEURS
</div>
<div class="grid grid-cols-2">
<RouterLink v-for="(creator, index) in creators"
:key="index"
:to="creator.routerLink">
<creator-card :creator="creator"
class="m-2">
</creator-card>
</RouterLink>
</div>
</div>
</template>
<script setup>
import CreatorCard from "@/views/browser/CreatorCard.vue";
</script>

View File

@@ -0,0 +1,250 @@
<template>
<div class="shadow-md rounded-2xl bg-gray-50 border custom-border">
<div>
<v-card-title>
<div class="flex flex-row justify-between items-center">
<div class="flex items-center">
<img
:src="props.content.createdByPortraitUrl"
alt="Profile Image"
class="rounded-full"
width="32px"
height="32px">
<div class="capitalize px-2">
{{ props.content.createdByName }}
</div>
<span class="text-subtitle-2 mt-1">
{{ time_ago(props.content.createdAt) }}
</span>
</div>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn variant="plain" v-bind="props">
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item v-if="creatorIsCurrentUser" @click="editContent">
<v-list-item-title>Modifier le contenu</v-list-item-title>
</v-list-item>
<v-list-item v-if="creatorIsCurrentUser" @click="openDeleteConfirmationDialog">
<v-list-item-title>Effacer le contenu</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<div class="uppercase">
{{ props.content.title }}
</div>
<div>
{{ props.content.description }}
</div>
</v-card-title>
<v-carousel
hide-delimiters
v-if="hasUrls"
:show-arrows="props.content.urls.length > 1"
:show-indicators="props.content.urls.length > 1"
>
<v-carousel-item
v-for="url in props.content.urls"
:key="url"
class="image-container"
@click="redirectToContent"
>
<component :is="getComponent(url)" :src="url"></component>
</v-carousel-item>
</v-carousel>
</div>
<div class="px-4">
<div class="flex justify-around py-2">
<Reaction :content="content"></Reaction>
<v-btn
:class="{'comment-active': hasMessages}"
icon="true"
variant="plain"
@click="toggleComments">
<v-icon>mdi-comment-outline</v-icon>
{{ messageCount }}
</v-btn>
<donation-button></donation-button>
</div>
<div :class="{'hidden': !messagesVisible}">
<h2 class="font-sans font-semibold mt-2">Commentaires</h2>
<message-list
:subject-id="props.content.id"
:messages="messages"
></message-list>
</div>
<div class="py-2">
<post-message :subject-id="props.content.id"
@message-posted="addMessage"
></post-message>
</div>
</div>
</div>
<v-dialog v-model="openDeleteConfirmationModal" max-width="500">
<v-form>
<v-card class="text-center rounded-xl"
:style="{
border: `2px solid `
}">
<div class="flex items-center justify-between py-4 text-2xl font-bold border-b mb-2">
<div class="flex-1 text-center">
{{$t('contentCard.deletecontenttitle')}}
</div>
<v-btn icon @click="openDeleteConfirmationModal = false" class="ml-auto mr-2" variant="text">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div class=" mr-2">
Êtes-vous sûr de vouloir supprimer le contenu ?
</div>
<div class="py-2 space-x-3">
<v-btn variant="flat"
@click="deleteContent()" class=" mt-5">
Oui
</v-btn>
<v-btn variant="outlined"
@click="openDeleteConfirmationModal = false" class=" mt-5">
Non
</v-btn>
</div>
</v-card>
</v-form>
</v-dialog>
</template>
<script setup>
import {computed, onBeforeMount, ref} from 'vue';
import {time_ago} from "@/internal_time_ago.js";
import MessageList from "@/views/messages/MessageList.vue";
import PostMessage from "@/views/messages/PostMessage.vue";
import DonationButton from "@/views/creators/DonationButton.vue";
import YoutubePlayer from './YoutubePlayer.vue';
import ImageViewer from './ImageViewer.vue';
import {useClient} from "@/plugins/api.js";
import {useAuthStore} from "@/stores/authStore.js";
import Reaction from "@/views/contents/Reaction.vue";
import {useMessageStore} from "@/stores/messageStore.js";
const props = defineProps({
content: {
type: Object,
required: true,
}
});
const openDeleteConfirmationModal = ref(false);
const emits = defineEmits(['content-deleted'])
const contentId = computed(() => props.content.id)
const creatorId = computed(() => props.content.createdBy)
const creatorName = computed(() => props.content.createdByName)
const creatorLogo = computed(() => props.content.createdByPortraitUrl)
const colorMenu = computed(() => props.content.colorMenu)
const colorAccent = computed(() => props.content.colorAccent)
const authStore = useAuthStore()
const creatorIsCurrentUser = computed(() => authStore.isAuthenticated && authStore.userId === creatorId.value)
const messageStore = useMessageStore();
const messageCount = ref(0);
const hasUrls = computed(() => !!props.content.urls && props.content.urls.length > 0);
const messagesVisible = ref(false);
const messages = ref([]);
const hasMessages = computed(() => messages.value.length > 0)
onBeforeMount(async () => {
messageCount.value = await messageStore.fetchMessageCount(contentId.value)
})
function openDeleteConfirmationDialog() {
openDeleteConfirmationModal.value = true;
}
function addMessage(newMessage) {
messages.value.unshift(newMessage);
messagesVisible.value = true;
messageCount.value ++;
}
function toggleComments() {
messagesVisible.value = !messagesVisible.value;
}
function likeContent() {
console.log('Content liked');
}
function dislikeContent() {
console.log('Content disliked');
}
function getComponent(url) {
if (url.includes('youtube.com') || url.includes('youtu.be')) {
return YoutubePlayer;
} else if (url.match(/\.(jpeg|jpg|gif|png)$/)) {
return ImageViewer;
}
}
function editContent() {
console.log('Modifier le contenu');
}
async function deleteContent() {
const client = useClient()
const response = await client.delete(`/api/contents/${contentId.value}`)
if (response.status >= 200 && response.status < 300) {
emits('content-deleted', contentId.value)
}
}
function redirectToContent() {
window.location.href = `/content/${props.content.id}`;
}
</script>
<style>
.image-container {
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
cursor: pointer;
}
.image-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
.custom-border {
border-color: #EAEBEC;
}
.hidden {
display: none;
}
.comment-active .v-icon {
color: #D63DAB;
}
</style>

View File

@@ -0,0 +1,115 @@
<script setup>
import { ref, onMounted } from 'vue';
import HTMLContentEditor from "@/views/contents/HTMLContentEditor.vue";
import QuickyContentEditor from "@/views/contents/QuickyContentEditor.vue";
import { useCreatorProfileStore } from "@/stores/creatorProfileStore.js";
import { useClient } from "@/plugins/api.js";
const showQuickyEditor = ref(true);
const showHtmlEditor = ref(false);
const toggleQuickyEditor = () => {
showQuickyEditor.value = true;
showHtmlEditor.value = false;
};
const toggleHtmlEditor = () => {
showHtmlEditor.value = true;
showQuickyEditor.value = false;
};
const client = useClient();
const creatorProfileStore = useCreatorProfileStore();
const creatorData = ref(null);
const isLoading = ref(true); // Indicateur de chargement
const fetchCreatorData = async () => {
const creatorName = creatorProfileStore.creator?.name;
if (!creatorName) {
console.error("Nom du créateur introuvable dans le store.");
return;
}
try {
const response = await client.get(`/api/creators/@${creatorName}`);
creatorData.value = response.data;
console.log("Données du créateur récupérées :", creatorData.value);
} catch (error) {
console.error(`Erreur lors de la récupération des données du créateur : ${error.response?.data || error.message}`);
} finally {
isLoading.value = false; // Indique que le chargement est terminé
}
};
// Appeler la fonction lors du montage du composant
onMounted(() => {
fetchCreatorData();
});
</script>
<template>
<div v-if="isLoading" class="flex items-center justify-center h-screen">
Chargement en cours...
</div>
<div v-else class="flex flex-col h-screen" :style="{ backgroundColor: creatorData.colors?.background, color: creatorData?.colors?.onSurface}">
<div class="max-w-[1000px] mx-auto shadow-2xl rounded-lg overflow-hidden">
<header class="text-2xl text-center py-4" :style="{ backgroundColor: creatorData?.colors?.primary, color: creatorData?.colors?.onPrimary }">
Éditeur de contenu
</header>
<div class="flex flex-grow">
<aside class="side-menu flex flex-col items-center py-6 " :style="{ backgroundColor: creatorData?.colors?.secondary, color: creatorData?.colors?.onSecondary }">
<div class="text-xl uppercase mb-6 px-2">Type de contenu</div>
<v-btn
:variant="showQuickyEditor ? 'elevated' : 'plain'"
@click="toggleQuickyEditor"
class="mb-4 normal-button"
>
Quicky
</v-btn>
</aside>
<main>
<div v-if="showQuickyEditor">
<QuickyContentEditor :creator-data="creatorData" />
</div>
<div v-if="showHtmlEditor">
<HTMLContentEditor />
</div>
</main>
</div>
</div>
</div>
</template>
<style scoped>
.side-menu {
background-color: #f7f7f7;
border-right: 1px solid #ccc;
}
.v-btn {
width: 80%;
}
.normal-button {
padding: 0.5rem 1rem;
font-size: 1rem;
}
header {
font-weight: bold;
}
main {
padding: 2rem;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<div>
<v-infinite-scroll :items="contents" :onLoad="fetchContents">
<div class="grid gap-2 -mt-4"
:class="{
'grid-cols-1': isExtraSmallScreen,
'grid-cols-2': isSmallScreen,
'grid-cols-3': isMediumScreen,
'grid-cols-4': isLargeScreen,
'grid-cols-5': isExtraLargeScreen
}">
<template v-for="content in contents" :key="content.id">
<component
:is="isSmallScreen ? ContentCardSm : ContentCardNormal"
:content="content"
@content-deleted="onContentDeleted"
></component>
</template>
</div>
<template v-slot:empty >
<div class="py-2" :style="{color:branding.colors.onSurface}">Il n'y a pas plus de contenu</div>
</template>
<template v-slot:error>
<v-alert type="error">{{ errorMessage }}</v-alert>
</template>
</v-infinite-scroll>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import ContentCardNormal from "@/views/contents/contentcards/NContentCard.vue";
import ContentCardSm from "@/views/contents/contentcards/SmContentCard.vue";
import { useClient } from '@/plugins/api.js';
import { useBrandingStore } from "@/stores/brandingStore.js";
const branding = useBrandingStore();
const props = defineProps({
creatorId: {
type: String,
required: true
}
});
const client = useClient();
const contents = ref([]);
const errorMessage = ref();
let last_id = null;
const isExtraSmallScreen = ref(false);
const isSmallScreen = ref(false);
const isMediumScreen = ref(false);
const isLargeScreen = ref(false);
const isExtraLargeScreen = ref(false);
const updateScreenSize = () => {
isExtraSmallScreen.value = window.matchMedia('(max-width: 640px)').matches;
isSmallScreen.value = window.matchMedia('(min-width: 641px) and (max-width: 768px)').matches;
isMediumScreen.value = window.matchMedia('(min-width: 769px) and (max-width: 1024px)').matches;
isLargeScreen.value = window.matchMedia('(min-width: 1025px) and (max-width: 1280px)').matches;
isExtraLargeScreen.value = window.matchMedia('(min-width: 1281px)').matches;
};
onMounted(() => {
updateScreenSize();
window.addEventListener('resize', updateScreenSize);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', updateScreenSize);
});
async function onContentDeleted(contentId) {
contents.value = contents.value.filter(c => c.id !== contentId);
}
const creatorIdWatcher = watch(
() => props.creatorId,
(newCreatorId) => {
if (newCreatorId) {
contents.value = [];
last_id = null;
fetchContents({ done: () => {} });
}
}
);
async function fetchContents({ done, page_size = 10 }) {
if (props.creatorId == null) return;
try {
let uri = `/api/contents/creator/${props.creatorId}?page_size=${page_size}`;
if (last_id !== null) uri = uri + `&last_id=${last_id}`;
const response = await client.get(uri);
if (response.status >= 200 && response.status < 300) {
const contentCount = response.data.length;
if (contentCount > 0) {
contents.value.push(...response.data);
const [last_content] = response.data.slice(-1);
last_id = last_content.id;
}
if (contentCount < page_size)
done('empty');
else
done('ok');
}
} catch (error) {
console.error("Failed to fetch posts", error);
errorMessage.value = error.message || "Failed to fetch contents";
done('error');
}
}
</script>

View File

@@ -0,0 +1,13 @@
<template>
<div class="d-sm-block d-md-block d-lg-none mb-1">
<full-screen-content-sm></full-screen-content-sm>
</div>
<div class="d-none d-lg-flex">
<full-screen-content-md></full-screen-content-md>
</div>
</template>
<script setup>
import FullScreenContentSm from "@/views/contents/contentfullscreen/FullScreenContentSm.vue";
import FullScreenContentMd from "@/views/contents/contentfullscreen/FullScreenContentMd.vue";
</script>

View File

@@ -0,0 +1,194 @@
<script setup>
import {ref} from 'vue';
import Editor from '@tinymce/tinymce-vue';
import '@tinymce/tinymce-vue';
import {useClient} from "@/plugins/api.js";
import {useUserProfileStore} from "@/stores/userProfileStore.js";
import {v7} from "uuid";
import {useRouter} from "vue-router";
const router = useRouter();
const client = useClient();
const tinymceScriptSrc = '/tinymce/js/tinymce/tinymce.min.js';
const content = ref('');
const title = ref('');
let lastUploadedFileName = '';
const isSnackbarOpen = ref(false);
const snackbarTimeout = ref(2000);
const snackbarText = ref('');
const snackbarColor = ref('red');
const selectedBackgroundColor = ref('#f0f0f0');
const userStore = useUserProfileStore();
// Custom image upload handler
const imagesUploadHandler = async (blobInfo) => {
const formData = new FormData();
formData.append('id', v7());
formData.append('files', blobInfo.blob(), lastUploadedFileName);
formData.append('creatorId', userStore.user.id);
let response = await client.post("/api/content/insert-image", formData);
let imageUrl = response.data[0];
/* global tinymce */
const editor = tinymce.activeEditor;
const images = editor.dom.select('img');
const lastImage = images.find(x => x.alt = lastUploadedFileName);
if (lastImage) {
// Replace the source of the image
editor.dom.setAttrib(lastImage, 'src', imageUrl);
editor.dom.setAttrib(lastImage, 'alt', lastUploadedFileName);
// Adds the change to the undo stack
editor.undoManager.add();
} else {
console.error('No image found in the content.');
}
};
const filePickerCallback = (callback, value, meta) => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.onchange = function () {
const file = input.files[0];
const reader = new FileReader();
reader.onload = function () {
lastUploadedFileName = file.name;
callback(reader.result);
}
reader.readAsDataURL(file);
};
input.click();
};
const saveAsync = async () => {
if (title.value === '') {
snackbarText.value = "Vous avez besoin d'un titre";
isSnackbarOpen.value = true;
}
const fullHtmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title.value}</title>
</head>
<body>
${content.value}
</body>
</html>`;
const formData = new FormData();
formData.append('id', v7());
formData.append('creatorId', userStore.user.id);
formData.append('title', title.value);
formData.append('htmlContent', fullHtmlContent);
const response = await client.post("/api/contents/html", formData);
if (response.status === 200) {
snackbarText.value = "Publier";
snackbarColor.value = "green";
isSnackbarOpen.value = true;
router.go(-1)
}
};
const setupTinyMCE = (editor) => {
// Custom button for selecting background color
editor.ui.registry.addButton('myCustomBgColorButton', {
text: 'Page BG Color',
onAction: function () {
editor.windowManager.open({
title: 'Select Page Background Color',
body: {
type: 'panel',
items: [
{
type: 'colorpicker',
name: 'colorpicker',
label: 'Background Color'
}
]
},
buttons: [
{
text: 'Save',
type: 'submit',
primary: true
}
],
onSubmit: function (dialog) {
console.log('supppp');
const color = dialog.getData().colorpicker;
console.log(color);
selectedBackgroundColor.value = color;
// Insert style into TinyMCE's content
const styleTag = `<style>body { background-color: ${color}; }</style>`;
editor.execCommand('mceInsertContent', false, styleTag);
dialog.close(); // Close dialog after selecting color
}
});
}
});
}
</script>
<template>
<v-snackbar v-model="isSnackbarOpen" :timeout="snackbarTimeout">
{{ snackbarText }}
<template v-slot:actions>
<v-btn :color="snackbarColor" variant="text" @click="isSnackbarOpen = false">
Fermer
</v-btn>
</template>
</v-snackbar>
<div class="flex flex-col items-center justify-start">
Html
<v-btn class="mb-4 text-xl px-6 py-3" @click="router.go(-1)">Return</v-btn>
<v-text-field
v-model="title"
placeholder="Title"
style="width: 100%; font-size: 1.5rem; padding: 10px;"
></v-text-field>
<Editor
style="max-width: 500px; width: 50%; font-size: 1.5rem; padding: 10px; height: 120%"
:tinymceScriptSrc="tinymceScriptSrc"
v-model="content"
:init="{
branding: false,
promotion: false,
plugins: 'lists link emoticons image imagetools code help wordcount media autoresize textcolor colorpicker',
block_formats: 'Paragraph=p; Header 1=h1; Header 2=h2; Header 3=h3',
toolbar: 'undo redo image align myCustomBgColorButton',
automatic_uploads: true,
file_picker_types: 'image',
min_height: 600,
max_height: 1200,
images_upload_handler: imagesUploadHandler,
file_picker_callback: filePickerCallback,
// setup: setupTinyMCE, Possible to change background color of the html
}"
/>
<v-btn @click="saveAsync()">POST</v-btn>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,31 @@
<template>
<div class="image-container">
<img :src="src" alt="Image" class="full-size-image" />
</div>
</template>
<script setup>
const props = defineProps({
src: {
type: String,
required: true,
},
});
</script>
<style scoped>
.image-container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
overflow: hidden;
}
.image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<div class="flex flex-column">
<div class="h-full bg-yellow p-2 rounded-2xl m-4">
<post-content-menu></post-content-menu>
</div>
<div class="flex flex-column m-4 gap-4">
<v-form>
<v-file-input
v-model="selectedFile"
label="Choisisez votre contenu"
accept="image/*"
prepend-icon="mdi-camera"
@change="onFileSelected"
></v-file-input>
<v-img
v-if="url"
:src="url"
max-height="375"
contain
></v-img>
<v-text-field
v-model="title"
density="comfortable"
variant="outlined"
label="Titre"
hide-details
clearable>
</v-text-field>
<v-text-field
v-model="description"
density="comfortable"
variant="outlined"
label="Description"
hide-details
clearable>
</v-text-field>
</v-form>
</div>
<div class="flex flex-row gap-2 p-2 justify-end">
<v-btn style="border-radius: 20px" variant="text">Canceller</v-btn>
<v-btn style="border-radius: 20px" @click="publish">Publier</v-btn>
</div>
</div>
</template>
<script setup>
// import posts from "@/views/posts/posts.json";
import {useClient} from '@/plugins/api.js';
import {ref} from 'vue';
import PostContentMenu from "@/views/contents/PostContentMenu.vue";
const props = defineProps({
contentId: {
type: String,
required: true
}
})
const client = useClient()
const selectedFile = ref("")
const url = ref("")
const title = ref("")
const description = ref("")
const onFileSelected = () => {
if (selectedFile.value) {
const fileReader = new FileReader();
fileReader.readAsDataURL(selectedFile.value);
fileReader.onload = () => {
url.value = fileReader.result;
};
}
};
const publish = async () => {
const response = await client.post(
`/api/contents/`,
{
"url": url.value,
"title": title.value,
"description": description.value
})
if (response.status !== 200) {
console.info(`Content created!`)
} else {
console.error(`Failed to create content ${response.data}`)
}
}
</script>

View File

@@ -0,0 +1,148 @@
<template>
<div class="flow">
<button @click="selectType('title')">
<v-icon>mdi-format-title</v-icon>
</button>
<button @click="selectType('text')">
<v-icon>mdi-text</v-icon>
</button>
<button @click="selectType('image')">
<v-icon>mdi-image</v-icon>
</button>
<button @click="selectType('video')">
<v-icon>mdi-video</v-icon>
</button>
<button @click="selectType('audio')">
<v-icon>mdi-volume-high</v-icon>
</button>
<button @click="selectType('comments')">
<v-icon>mdi-comment</v-icon>
</button>
</div>
<!-- Affichage du contenu en fonction du type sélectionné -->
<!-- <v-card-text>-->
<!-- <v-row v-for="(content, index) in contents" :key="index" class="draggable-row"-->
<!-- @dragstart="dragStart(index)" @dragover.prevent @drop="drop(index)" draggable="true">-->
<!-- <v-col cols="10">-->
<!-- <template v-if="content.type === 'title'">-->
<!-- <v-text-field v-model="content.value" label="Titre"></v-text-field>-->
<!-- </template>-->
<!-- <template v-else-if="content.type === 'text'">-->
<!-- <v-textarea v-model="content.value" label="Texte"></v-textarea>-->
<!-- </template>-->
<!-- <template v-else-if="content.type === 'image'">-->
<!-- <v-row>-->
<!-- <v-col cols="12">-->
<!-- <v-file-input v-model="content.value" label="Image"></v-file-input>-->
<!-- </v-col>-->
<!-- </v-row>-->
<!-- </template>-->
<!-- <template v-else-if="content.type === 'video'">-->
<!-- <v-text-field v-model="content.value" label="URL de la vidéo"></v-text-field>-->
<!-- </template>-->
<!-- <template v-else-if="content.type === 'audio'">-->
<!-- <v-row>-->
<!-- <v-col cols="2">-->
<!-- <v-icon>mdi-volume-high</v-icon>-->
<!-- </v-col>-->
<!-- <v-col cols="10">-->
<!-- <v-file-input v-model="content.value" label="Audio"></v-file-input>-->
<!-- </v-col>-->
<!-- </v-row>-->
<!-- </template>-->
<!-- <template v-else-if="content.type === 'comments'">-->
<!-- <v-text-field v-model="content.value" label="Commentaires"></v-text-field>-->
<!-- </template>-->
<!-- </v-col>-->
<!-- <v-col cols="2" class="d-flex justify-center align-center">-->
<!-- <button icon @click="removeContent(index)" class="remove-button">-->
<!-- <v-icon>mdi-close</v-icon>-->
<!-- </button>-->
<!-- </v-col>-->
<!-- </v-row>-->
<!-- </v-card-text>-->
<!-- &lt;!&ndash; Boutons Post, Preview et Cancel &ndash;&gt;-->
<!-- <v-row v-if="contents.length > 0" justify="end" style="margin-bottom: 10px;">-->
<!-- <v-col class="d-flex justify-end" style="margin-right: 4%;">-->
<!-- <button style="margin-right: 15px;" @click="postContent" color="white" dark-->
<!-- elevation="4">Post-->
<!-- </button>-->
<!-- <button style="margin-right: 15px;" @click="previewContent" color="white" dark-->
<!-- elevation="5">Preview-->
<!-- </button>-->
<!-- <button @click="cancelPost" color="white" dark elevation="5">Cancel</button>-->
<!-- </v-col>-->
<!-- </v-row>-->
</template>
<script setup>
import {ref} from 'vue';
const contents = ref([]);
let dragIndex = null;
const selectType = (type) => {
console.log("Type sélectionné:", type);
contents.value.push({type: type, value: ''});
};
const removeContent = (index) => {
contents.value.splice(index, 1);
};
const postContent = () => {
// Implémenter la logique pour poster le contenu
};
const previewContent = () => {
// Implémenter la logique pour prévisualiser le contenu
};
const cancelPost = () => {
if (contents.value.length > 0) {
// Réinitialiser le tableau contents pour supprimer tous les contenus
contents.value = [];
}
};
const dragStart = (index) => {
dragIndex = index;
};
const drop = (index) => {
if (dragIndex !== null && index !== null) {
const draggedItem = contents.value[dragIndex];
contents.value.splice(dragIndex, 1);
contents.value.splice(index, 0, draggedItem);
dragIndex = null;
}
};
</script>
<style scoped>
.remove-button {
display: flex;
justify-content: center;
align-items: center;
margin-top: -20%;
}
.toolbar-button {
margin-top: 10px;
margin-bottom: -15px;
}
.draggable-row {
cursor: grab;
}
</style>

View File

@@ -0,0 +1,157 @@
<script setup>
import {useClient} from '@/plugins/api.js';
import {ref} from 'vue';
import {v7} from 'uuid';
import {useCreatorProfileStore} from "@/stores/creatorProfileStore.js";
import {useBrandingStore} from "@/stores/brandingStore.js";
const emits = defineEmits(['content-posted'])
const brandingStore = useBrandingStore()
const creatorProfileStore = useCreatorProfileStore()
const isDialogActive = ref(false);
const client = useClient();
const title = ref('');
const message = ref('');
const files = ref([]);
const externalUrls = ref([]);
const addUrl = () => {
externalUrls.value.push('');
};
const removeUrl = (index) => {
externalUrls.value.splice(index, 1);
};
async function publishPost() {
const formData = new FormData();
formData.append('id', v7());
formData.append('creatorId', creatorProfileStore.creator.id);
formData.append('title', title.value);
formData.append('description', message.value);
files.value.forEach(file => {
formData.append('files', file);
});
externalUrls.value.forEach(externalUrl => {
formData.append('externalUrls', externalUrl);
});
try {
const content = await client.post(
`/api/contents/`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
}
})
emits('content-posted', content.data)
closeDialog();
} catch (error) {
console.error(error);
}
}
const cancelPost = () => {
closeDialog();
}
const closeDialog = () => {
isDialogActive.value = false;
title.value = '';
message.value = '';
files.value = [];
}
</script>
<template>
<button
class=" items-center transform transition-transform duration-200 hover:text-gray-300 hover:scale-125 px-4"
@click="isDialogActive = true">
<v-icon style="font-size: 25px; height: 25px; width: 55px;">mdi-text-box-plus-outline</v-icon>
</button>
<v-dialog v-model="isDialogActive" max-width="500">
<v-form>
<v-card class="text-center rounded-xl"
:style="{
border: `3px solid ${brandingStore.value.colors.menu}`
}">
<v-card-title class="font-medium">
Créer un Contenu
</v-card-title>
<v-card-text>
<v-text-field v-model="title"
class="p-2"
label="Titre"
density="comfortable"
variant="outlined"
hide-details
clearable
></v-text-field>
<v-textarea v-model="message"
label="Écrivez votre message ici..."
class="p-2"
density="comfortable"
variant="outlined"
hide-details
clearable
outlined
></v-textarea>
<div v-for="(url, index) in externalUrls" :key="index" class="d-flex align-center">
<v-text-field
v-model="externalUrls[index]"
class="p-2 flex-grow-1"
label="Url Externe"
density="comfortable"
variant="outlined"
hide-details
></v-text-field>
<v-btn icon @click="removeUrl(index)" class="ml-2">
<v-icon>mdi-minus</v-icon>
</v-btn>
</div>
<v-btn icon @click="addUrl" class="mt-2">
<v-icon>mdi-plus</v-icon>
</v-btn>
<v-file-input v-model="files"
label="Glissez vos images"
class="p-2 custom-file-input"
variant="outlined"
multiple
dropzone
prepend-icon=""
placeholder="Glissez et déposez des fichiers ici ou cliquez pour sélectionner des fichiers"
></v-file-input>
</v-card-text>
<v-card-actions>
<v-btn variant="flat"
@click="cancelPost"
class="p-20">
Cancel
</v-btn>
<v-btn variant="flat"
color="primary"
@click="publishPost">
Publier
</v-btn>
</v-card-actions>
</v-card>
</v-form>
</v-dialog>
</template>

View File

@@ -0,0 +1,304 @@
<script setup>
import { ref, computed, watch } from "vue";
import { useClient } from "@/plugins/api.js";
import { v7 } from "uuid";
import { useCreatorProfileStore } from "@/stores/creatorProfileStore.js";
import { useRouter } from "vue-router";
const client = useClient();
const router = useRouter();
const step = ref(1);
const title = ref('');
const message = ref('');
const files = ref([]);
const Thumbnail = ref();
const ThumbnailPreview = ref(null);
const externalUrls = ref([]);
const warningMessage = ref('');
const creatorProfileStore = useCreatorProfileStore();
const carouselIndex = ref(0);
const carouselItems = computed(() => {
const images = files.value.map(file => URL.createObjectURL(file));
const videos = externalUrls.value.filter(url => url.trim() !== '');
return [...images, ...videos];
});
watch(Thumbnail, (newFile) => {
if (newFile) {
ThumbnailPreview.value = URL.createObjectURL(newFile);
} else {
ThumbnailPreview.value = null;
}
});
const addUrl = () => externalUrls.value.push('');
const removeUrl = (index) => externalUrls.value.splice(index, 1);
const nextCarouselItem = () => {
if (carouselItems.value.length > 0) {
carouselIndex.value = (carouselIndex.value + 1) % carouselItems.value.length;
}
};
const previousCarouselItem = () => {
if (carouselItems.value.length > 0) {
carouselIndex.value =
(carouselIndex.value - 1 + carouselItems.value.length) %
carouselItems.value.length;
}
};
const goToContentEditor = () => {
if (!title.value || !Thumbnail.value) {
warningMessage.value = 'Veuillez sélectionner un thumbnail et entrer un titre avant de continuer.';
return;
}
warningMessage.value = '';
step.value = 2;
};
const resetForm = () => {
title.value = '';
message.value = '';
files.value = [];
Thumbnail.value = null;
ThumbnailPreview.value = null;
externalUrls.value = [];
step.value = 1;
};
const publishPost = async () => {
if (!Thumbnail.value) {
alert("Veuillez ajouter un thumbnail avant de publier.");
return;
}
const formData = new FormData();
formData.append('id', v7());
formData.append('creatorId', creatorProfileStore.creator.id);
formData.append('title', title.value);
formData.append('description', message.value);
formData.append('Thumbnail', Thumbnail.value);
files.value.forEach(file => formData.append('files', file));
externalUrls.value.forEach(url => formData.append('externalUrls', url));
try {
const response = await client.post(`/api/contents`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
console.log('Content published:', response.data);
const creatorName = creatorProfileStore.creator.name;
router.push(`/@${creatorName}/content`);
resetForm();
} catch (error) {
console.error('Error publishing content:', error);
}
};
</script>
<template>
<div class="mx-auto max-w-xl p-4 shadow-md rounded-lg bg-white overflow-y-auto w-[1000px]">
<!-- Thumbnail Editor -->
<div v-if="step === 1" class="flex flex-col items-center">
<h2 class="text-lg font-bold mb-4">Éditeur de Thumbnail</h2>
<!-- Thumbnail Preview -->
<div
class="shadow-md rounded-md bg-gray-50 border custom-border w-[400px] h-[250px] mb-4 flex items-center justify-center cursor-pointer"
@click="$refs.thumbnailInput.click()"
>
<img
v-if="ThumbnailPreview"
:src="ThumbnailPreview"
class="rounded-md w-full h-full object-cover"
alt="Thumbnail Preview"
/>
<div v-else class="text-gray-500">Cliquez pour sélectionner une image</div>
</div>
<!-- Titre -->
<v-text-field
v-model="title"
label="Titre"
density="comfortable"
variant="outlined"
clearable
class="mb-4 w-[400px]"
/>
<!-- Upload Thumbnail -->
<v-file-input
ref="thumbnailInput"
v-model="Thumbnail"
label="Télécharger un Thumbnail"
variant="outlined"
dropzone
clearable
class="mb-6 w-[400px]"
/>
<!-- Message d'avertissement -->
<p v-if="warningMessage" class="text-red-500 text-sm mb-4">{{ warningMessage }}</p>
<!-- Bouton pour passer à l'étape suivante -->
<v-btn color="primary" variant="contained" class="w-[400px]" @click="goToContentEditor">
Next
</v-btn>
</div>
<!-- Content Editor -->
<div v-if="step === 2" class="flex flex-col items-center">
<h2 class="text-lg font-bold mb-4">Content Editor</h2>
<!-- Carrousel -->
<div class="relative w-[400px] h-[250px] mb-6">
<div
class="absolute inset-0 flex items-center justify-center bg-black/50 rounded-md"
v-if="carouselItems.length === 0"
>
<p class="text-white">Aucun élément à afficher</p>
</div>
<div v-else>
<!-- Image ou vidéo en cours -->
<img
v-if="carouselItems[carouselIndex].includes('blob')"
:src="carouselItems[carouselIndex]"
class="carousel-item"
alt="Carrousel Image"
/>
<iframe
v-else
:src="`${carouselItems[carouselIndex]}?autoplay=1&mute=1`"
frameborder="0"
allow="autoplay"
class="carousel-item"
allowfullscreen
></iframe>
</div>
<!-- Flèches pour naviguer -->
<button
class="absolute top-1/2 left-2 transform -translate-y-1/2 text-white bg-black/50 rounded-full p-2"
@click="previousCarouselItem"
>
<v-icon>mdi-chevron-left</v-icon>
</button>
<button
class="absolute top-1/2 right-2 transform -translate-y-1/2 text-white bg-black/50 rounded-full p-2"
@click="nextCarouselItem"
>
<v-icon>mdi-chevron-right</v-icon>
</button>
</div>
<!-- Message -->
<v-textarea
v-model="message"
label="Message"
density="comfortable"
variant="outlined"
clearable
class="mb-4 w-[400px]"
/>
<!-- Ajout des URLs externes -->
<div v-for="(url, index) in externalUrls" :key="index" class="flex space-x-2 w-[400px]">
<v-text-field
v-model="externalUrls[index]"
label="Lien URL"
density="comfortable"
variant="outlined"
class="flex-1"
/>
<v-btn icon color="error" @click="removeUrl(index)">
<v-icon>mdi-minus</v-icon>
</v-btn>
</div>
<div class="w-[400px] mb-10">
<div class="flex items-center gap-2">
<v-btn icon color="primary" @click="addUrl">
<v-icon>mdi-plus</v-icon>
</v-btn>
<span class="text-sm text-gray-500">Cliquez pour ajouter un nouveau lien vidéo</span>
</div>
</div>
<!-- Upload Images -->
<v-file-input
v-model="files"
label="Télécharger des Images"
variant="outlined"
multiple
dropzone
class="mb-6 w-[400px]"
/>
<!-- Boutons de navigation -->
<div class="flex w-[400px] justify-between">
<v-btn variant="outlined" @click="step = 1">
Back
</v-btn>
<v-btn color="primary" variant="contained" @click="publishPost">
Publier
</v-btn>
</div>
</div>
</div>
</template>
<style scoped>
.relative {
position: relative;
}
.absolute {
position: absolute;
}
.transform {
transform: translateY(-50%);
}
.bg-black\/50 {
background-color: rgba(0, 0, 0, 0.5);
}
.rounded-md {
border-radius: 0.375rem;
}
.text-white {
color: #fff;
}
.p-2 {
padding: 0.5rem;
}
.text-red-500 {
color: #f56565;
}
.custom-border {
border-color: #eaebec;
}
/* Taille fixe pour les éléments du carrousel */
.carousel-item {
width: 400px;
height: 250px;
object-fit: cover;
border-radius: 0.375rem;
display: block;
}
</style>

View File

@@ -0,0 +1,332 @@
<script setup>
import { REACTIONS } from "@/Constants/Reactions.js";
import { computed, ref } from "vue";
import { useClient } from "@/plugins/api.js";
import {useAuthStore} from "@/stores/authStore.js"
import MustBeLogged from "@/views/MustBeLogged.vue";
import {useUserProfileStore} from "@/stores/userProfileStore.js";
const userProfileStore = useUserProfileStore();
const authStore = useAuthStore()
const props = defineProps({
content: {
type: Object,
required: true,
},
});
const contentId = computed(() => props.content.id);
const hasReacted = ref(false);
const currentReaction = ref(null);
const likeCount = ref(0);
const dislikeCount = ref(0);
const loveCount = ref(0);
const hahaCount = ref(0);
const wowCount = ref(0);
const sadCount = ref(0);
const angryCount = ref(0);
const menuVisible = ref(false);
const holdTimeout = ref(null);
const hideTimeout = ref(null);
const touchTimeout = ref(null);
const loginModal = ref(false);
initializeReactions();
async function reactToContent(reaction) {
if (!authStore.isAuthenticated) {
loginModal.value = true;
return;
}
const client = useClient();
if (!hasReacted.value) {
const request = {
ContentId: contentId.value,
reaction: reaction,
userId: userProfileStore.user.id,
userName: `${userProfileStore.user.firstName} ${userProfileStore.user.lastName}`,
};
adjustReactionCount(reaction);
await client.post("/api/content/reaction/", request);
hasReacted.value = true;
console.log(`Added ${reaction} reaction to content.`);
} else if (reaction !== currentReaction.value) {
const requestAdd = {
ContentId: contentId.value,
reaction: reaction,
userId: userProfileStore.user.id,
userName: `${userProfileStore.user.firstName} ${userProfileStore.user.lastName}`,
};
adjustReactionCount(reaction);
await client.post("/api/content/reaction/", requestAdd);
console.log(`Changed reaction to ${reaction} on content.`);
} else {
const requestRemove = {
ContentId: contentId.value,
userId: userProfileStore.user.id,
};
adjustReactionCount(reaction);
await client.post("/api/content/reaction/remove", requestRemove);
hasReacted.value = false;
console.log("Reaction to content removed.");
}
setTimeout(() => {
menuVisible.value = false;
}, 500);
}
function adjustReactionCount(newReaction) {
if (currentReaction.value === newReaction) {
switch (newReaction) {
case REACTIONS.LIKE:
if (likeCount.value > 0) likeCount.value--;
break;
case REACTIONS.DISLIKE:
if (dislikeCount.value > 0) dislikeCount.value--;
break;
case REACTIONS.LOVE:
if (loveCount.value > 0) loveCount.value--;
break;
case REACTIONS.HAHA:
if (hahaCount.value > 0) hahaCount.value--;
break;
case REACTIONS.WOW:
if (wowCount.value > 0) wowCount.value--;
break;
case REACTIONS.SAD:
if (sadCount.value > 0) sadCount.value--;
break;
case REACTIONS.ANGRY:
if (angryCount.value > 0) angryCount.value--;
break;
}
currentReaction.value = null;
hasReacted.value = false;
} else {
if (currentReaction.value) {
switch (currentReaction.value) {
case REACTIONS.LIKE:
if (likeCount.value > 0) likeCount.value--;
break;
case REACTIONS.DISLIKE:
if (dislikeCount.value > 0) dislikeCount.value--;
break;
case REACTIONS.LOVE:
if (loveCount.value > 0) loveCount.value--;
break;
case REACTIONS.HAHA:
if (hahaCount.value > 0) hahaCount.value--;
break;
case REACTIONS.WOW:
if (wowCount.value > 0) wowCount.value--;
break;
case REACTIONS.SAD:
if (sadCount.value > 0) sadCount.value--;
break;
case REACTIONS.ANGRY:
if (angryCount.value > 0) angryCount.value--;
break;
}
}
switch (newReaction) {
case REACTIONS.LIKE:
likeCount.value++;
break;
case REACTIONS.DISLIKE:
dislikeCount.value++;
break;
case REACTIONS.LOVE:
loveCount.value++;
break;
case REACTIONS.HAHA:
hahaCount.value++;
break;
case REACTIONS.WOW:
wowCount.value++;
break;
case REACTIONS.SAD:
sadCount.value++;
break;
case REACTIONS.ANGRY:
angryCount.value++;
break;
}
currentReaction.value = newReaction;
hasReacted.value = true;
}
}
function initializeReactions() {
const userReaction = props.content.reactions.find((x) => x.userId === userProfileStore.user.id);
if (userReaction) {
currentReaction.value = userReaction.reaction;
hasReacted.value = true;
} else {
currentReaction.value = null;
hasReacted.value = false;
}
likeCount.value = props.content.reactions.filter((x) => x.reaction === REACTIONS.LIKE).length;
dislikeCount.value = props.content.reactions.filter((x) => x.reaction === REACTIONS.DISLIKE).length;
loveCount.value = props.content.reactions.filter((x) => x.reaction === REACTIONS.LOVE).length;
hahaCount.value = props.content.reactions.filter((x) => x.reaction === REACTIONS.HAHA).length;
wowCount.value = props.content.reactions.filter((x) => x.reaction === REACTIONS.WOW).length;
sadCount.value = props.content.reactions.filter((x) => x.reaction === REACTIONS.SAD).length;
angryCount.value = props.content.reactions.filter((x) => x.reaction === REACTIONS.ANGRY).length;
}
function showReactions() {
clearTimeout(hideTimeout.value);
menuVisible.value = true;
}
function hideReactions() {
hideTimeout.value = setTimeout(() => {
menuVisible.value = false;
}, 250);
}
function onTouchStart() {
touchTimeout.value = setTimeout(() => {
menuVisible.value = true;
}, 250);
}
function onTouchEnd() {
clearTimeout(touchTimeout.value);
}
function onMouseUp() {
clearTimeout(holdTimeout.value);
hideReactions();
}
function onMouseOver() {
if (!isMobileDevice()) {
showReactions();
}
}
function onMouseLeave() {
if (!isMobileDevice()) {
hideReactions();
}
}
function keepReactionMenuOpen(){
clearTimeout(hideTimeout.value);
}
function isMobileDevice() {
return window.innerWidth <= 800;
}
</script>
<template>
<div style="position: relative; display: inline-block;">
<v-menu
class="reaction-card"
v-model="menuVisible"
offset-y
:close-on-content-click="false"
transition="scale-transition"
:attach="$el"
>
<template v-slot:activator="{ on, attrs }">
<v-btn
v-bind="attrs"
:on="on"
icon="true"
variant="plain"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
@mouseup="onMouseUp"
@mouseover="onMouseOver"
@mouseleave="onMouseLeave"
@click="reactToContent(REACTIONS.LIKE)"
>
<v-icon :class="{'active-icon': currentReaction === REACTIONS.LIKE}">mdi-thumb-up-outline</v-icon>
{{ likeCount }}
</v-btn>
</template>
<v-card
class="reaction-card"
@mouseover="keepReactionMenuOpen"
@mouseleave="hideReactions"
>
<v-btn
variant="plain"
@click="reactToContent(REACTIONS.DISLIKE)"
>
<v-icon :class="{'active-icon': currentReaction === REACTIONS.DISLIKE}">mdi-thumb-down-outline</v-icon>
</v-btn>
<v-btn
variant="plain"
@click="reactToContent(REACTIONS.LOVE)"
>
<v-icon :class="{'active-icon': currentReaction === REACTIONS.LOVE}">mdi-heart-outline</v-icon>
{{ loveCount }}
</v-btn>
<v-btn
variant="plain"
@click="reactToContent(REACTIONS.HAHA)"
>
<v-icon :class="{'active-icon': currentReaction === REACTIONS.HAHA}">mdi-emoticon-excited-outline</v-icon>
{{ hahaCount }}
</v-btn>
<v-btn
variant="plain"
@click="reactToContent(REACTIONS.WOW)"
>
<v-icon :class="{'active-icon': currentReaction === REACTIONS.WOW}">mdi-emoticon-happy-outline</v-icon>
{{ wowCount }}
</v-btn>
<v-btn
variant="plain"
@click="reactToContent(REACTIONS.SAD)"
>
<v-icon :class="{'active-icon': currentReaction === REACTIONS.SAD}">mdi-emoticon-sad-outline</v-icon>
{{ sadCount }}
</v-btn>
<v-btn
variant="plain"
@click="reactToContent(REACTIONS.ANGRY)"
>
<v-icon :class="{'active-icon': currentReaction === REACTIONS.ANGRY}">mdi-emoticon-angry-outline</v-icon>
{{ angryCount }}
</v-btn>
</v-card>
</v-menu>
<must-be-logged v-model="loginModal" message="Vous devez être connecté pour réagir."></must-be-logged>
</div>
</template>
<style scoped>
.reaction-card {
display: flex;
justify-content: space-around;
padding: 8px;
margin-top: -35px;
margin-left: 100px;
}
.active-icon {
color: blue;
stroke: blue;
}
</style>

View File

@@ -0,0 +1,26 @@
<template>
<iframe
:src="src"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</template>
<script setup>
const props = defineProps({
src: {
type: String,
required: true,
},
});
</script>
<style scoped>
iframe {
width: 100%;
height: 100%;
border: 0;
}
</style>

View File

@@ -0,0 +1,121 @@
<script setup>
import { computed, onBeforeMount, ref } from 'vue';
import { time_ago } from "@/internal_time_ago.js";
import { useClient } from "@/plugins/api.js";
import { useAuthStore } from "@/stores/authStore.js";
import { useMessageStore } from "@/stores/messageStore.js";
import { useBrandingStore } from "@/stores/brandingStore.js";
const props = defineProps({
content: {
type: Object,
required: true,
}
});
const openDeleteConfirmationModal = ref(false);
const emits = defineEmits(['content-deleted']);
const contentId = computed(() => props.content.id);
const creatorId = computed(() => props.content.createdBy);
const Thumbnail = computed(() => props.content.thumbnailUrl);
const branding = useBrandingStore();
const authStore = useAuthStore();
const creatorIsCurrentUser = computed(() => authStore.isAuthenticated && authStore.userId === creatorId.value);
onBeforeMount(async () => {
const messageStore = useMessageStore();
messageStore.fetchMessageCount(contentId.value);
});
function openDeleteConfirmationDialog() {
openDeleteConfirmationModal.value = true;
}
async function deleteContent() {
const client = useClient();
const response = await client.delete(`/api/contents/${contentId.value}`);
if (response.status >= 200 && response.status < 300) {
emits('content-deleted', contentId.value);
}
}
function redirectToContent() {
window.location.href = `/content/${props.content.id}`;
}
function hexToRgb(hex) {
const bigint = parseInt(hex.replace('#', ''), 16);
return `${(bigint >> 16) & 255}, ${(bigint >> 8) & 255}, ${bigint & 255}`;
}
</script>
<style>
.custom-border {
border-color: #EAEBEC;
}
.comment-active .v-icon {
color: #D63DAB;
}
</style>
<template>
<div class="shadow-md rounded-md bg-gray-50 border custom-border w-52 h-[300px]"
:style="{
backgroundColor: branding.colors.surface,
boxShadow: '0 10px 10px rgba(0, 0, 0, 0.3)',
borderColor: `rgba(${hexToRgb(branding.colors.secondary)}, 0.4)`,
borderWidth: '1px',
}">
<img
v-if="props.content.thumbnailUrl"
:src="props.content.thumbnailUrl.replace(/[{}]/g, '')"
class="rounded-t-md w-[260px] h-[160px] object-cover cursor-pointer"
alt="Image Content"
@click="redirectToContent" />
<div class="p-1">
<div class="flex flex-row justify-between items-center">
<span class="text-caption mt-1 px-2" :style="{color:branding.colors.onSurface}">{{ time_ago(props.content.createdAt) }}</span>
<v-menu v-if="creatorIsCurrentUser" :offset-y="true">
<template v-slot:activator="{ props }">
<v-btn variant="plain" v-bind="props" style="min-width: auto; padding: 0; margin-right: 4px;">
<div :style="{color:branding.colors.onSurface}">
<v-icon>mdi-dots-vertical</v-icon>
</div>
</v-btn>
</template>
<v-list>
<v-list-item @click="openDeleteConfirmationDialog">
<v-list-item-title>{{$t('contentCard.delete')}}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</div>
<div class="capitalize p-2" :style="{color:branding.colors.onSurface}">{{ props.content.title }}</div>
</div>
<!-- Delete Dialog -->
<v-dialog v-model="openDeleteConfirmationModal" max-width="500">
<v-card class="text-center rounded-xl">
<div class="flex items-center justify-between py-4 text-2xl font-bold border-b mb-2">
<div class="flex-1 text-center">{{$t('contentCard.deletecontenttitle')}}</div>
<v-btn icon @click="openDeleteConfirmationModal = false" variant="text">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div>{{$t('contentCard.deeletecontentwarning')}}</div>
<div class="py-2 space-x-3">
<v-btn variant="flat" @click="deleteContent()" class="mt-5">{{$t('general.yes')}}</v-btn>
<v-btn variant="outlined" @click="openDeleteConfirmationModal = false" class="mt-5">{{$t('general.no')}}</v-btn>
</div>
</v-card>
</v-dialog>
</template>

View File

@@ -0,0 +1,246 @@
<template>
<div class="shadow-md bg-gray-50">
<div>
<v-card-title>
<div class="flex flex-row justify-between items-center">
<div class="flex items-center">
<img
:src="props.content.createdByPortraitUrl"
alt="Profile Image"
class="rounded-full"
width="32px"
height="32px">
<router-link class="capitalize px-2" :to="`/@${props.content.createdByName}`">
{{ props.content.createdByName }}
</router-link>
<span class="text-subtitle-2">
{{ time_ago(props.content.createdAt) }}
</span>
</div>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn variant="plain" v-bind="props">
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item v-if="creatorIsCurrentUser" @click="editContent">
<v-list-item-title>{{ $t('contentCard.edit') }}</v-list-item-title>
</v-list-item>
<v-list-item v-if="creatorIsCurrentUser" @click="openDeleteConfirmationDialog">
<v-list-item-title>{{ $t('contentCard.delete') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<div class="uppercase">
{{ props.content.title }}
</div>
<div>
{{ props.content.description }}
</div>
</v-card-title>
<v-carousel
hide-delimiters
v-if="hasUrls"
:show-arrows="props.content.urls.length > 1"
:show-indicators="props.content.urls.length > 1"
>
<v-carousel-item
v-for="url in props.content.urls"
:key="url"
class="image-container"
@click="redirectToContent"
>
<component :is="getComponent(url)" :src="url"></component>
</v-carousel-item>
</v-carousel>
</div>
<div class="px-1">
<div class="flex justify-around ">
<Reaction :content="content"></Reaction>
<v-btn
:class="{'comment-active': hasMessages}"
icon="true"
variant="plain"
@click="toggleComments">
<v-icon>mdi-comment-outline</v-icon>
{{ messageCount }}
</v-btn>
<donation-button></donation-button>
</div>
<div :class="{'hidden': !messagesVisible}">
<h2 class="font-sans font-semibold ">{{ $t('contentCard.commenttitle') }}</h2>
<message-list
:subject-id="props.content.id"
:messages="messages"
></message-list>
</div>
<div class="py-1">
<post-message :subject-id="props.content.id"
@message-posted="addMessage"
></post-message>
</div>
</div>
</div>
<v-dialog v-model="openDeleteConfirmationModal" max-width="500">
<v-form>
<v-card class="text-center rounded-xl"
:style="{
border: `2px solid `
}">
<div class="flex items-center justify-between py-4 text-2xl font-bold border-b mb-2">
<div class="flex-1 text-center">
{{$t('contentCard.deletecontenttitle')}}
</div>
<v-btn icon @click="openDeleteConfirmationModal = false" class="ml-auto mr-2" variant="text">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div class=" mr-2">
{{$t('contentCard.deeletecontentwarning')}}
</div>
<div class="py-2 space-x-3">
<v-btn variant="flat"
@click="deleteContent()" class=" mt-5">
{{$t('general.yes')}}
</v-btn>
<v-btn variant="outlined"
@click="openDeleteConfirmationModal = false" class=" mt-5">
{{$t('general.no')}}
</v-btn>
</div>
</v-card>
</v-form>
</v-dialog>
</template>
<script setup>
import {computed, onBeforeMount, ref} from 'vue';
import {time_ago} from "@/internal_time_ago.js";
import MessageList from "@/views/messages/MessageList.vue";
import PostMessage from "@/views/messages/PostMessage.vue";
import DonationButton from "@/views/creators/DonationButton.vue";
import YoutubePlayer from '../YoutubePlayer.vue';
import ImageViewer from '../ImageViewer.vue';
import {useClient} from "@/plugins/api.js";
import {useAuthStore} from "@/stores/authStore.js";
import Reaction from "@/views/contents/Reaction.vue";
import {useMessageStore} from "@/stores/messageStore.js";
const props = defineProps({
content: {
type: Object,
required: true,
}
});
const openDeleteConfirmationModal = ref(false);
const emits = defineEmits(['content-deleted'])
const contentId = computed(() => props.content.id)
const creatorId = computed(() => props.content.createdBy)
const authStore = useAuthStore()
const creatorIsCurrentUser = computed(() => authStore.isAuthenticated && authStore.userId === creatorId.value)
const messageStore = useMessageStore();
const messageCount = ref(0);
const hasUrls = computed(() => !!props.content.urls && props.content.urls.length > 0);
const messagesVisible = ref(false);
const messages = ref([]);
const hasMessages = computed(() => messages.value.length > 0)
onBeforeMount(async () => {
messageCount.value = await messageStore.fetchMessageCount(contentId.value)
})
function openDeleteConfirmationDialog() {
openDeleteConfirmationModal.value = true;
}
function addMessage(newMessage) {
messages.value.unshift(newMessage);
messagesVisible.value = true;
messageCount.value ++;
}
function toggleComments() {
messagesVisible.value = !messagesVisible.value;
}
function getComponent(url) {
if (url.includes('youtube.com') || url.includes('youtu.be')) {
return YoutubePlayer;
} else if (url.match(/\.(jpeg|jpg|gif|png)$/)) {
return ImageViewer;
}
}
function editContent() {
console.log('Modifier le contenu');
}
async function deleteContent() {
const client = useClient()
const response = await client.delete(`/api/contents/${contentId.value}`)
if (response.status >= 200 && response.status < 300) {
emits('content-deleted', contentId.value)
}
}
function redirectToContent() {
window.location.href = `/content/${props.content.id}`;
}
</script>
<style>
.image-container {
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
cursor: pointer;
}
.image-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
.custom-border {
border-color: #EAEBEC;
}
.hidden {
display: none;
}
.v-carousel-item {
padding: 0; /* Supprime tout padding interne */
margin: 0; /* Supprime toute marge interne */
width: 100vw; /* Assure que chaque item occupe toute la largeur de l'écran */
}
.comment-active .v-icon {
color: #D63DAB;
}
</style>

View File

@@ -0,0 +1,212 @@
<template>
<div class="flex h-[calc(100vh-118px)] -mt-2 w-full">
<div ref="containerRef" class="flex-1 flex items-center justify-center bg-neutral-500 max-h-screen relative">
<div class="absolute inset-0 z-0 bg-cover bg-center blur-lg pointer-events-none"
:style="{ backgroundImage: `url(${currentImage})` }"></div>
<div class="absolute top-8 left-4 z-20">
<v-btn @click="goBack" variant="plain" class="rounded-full text-white w-12 h-12 flex items-center justify-center">
<v-icon class="text-black bg-white rounded-full" size="36">mdi-close</v-icon>
</v-btn>
</div>
<div v-if="multipleImages" class="absolute left-0 top-1/2 transform -translate-y-1/2 z-20">
<v-btn @click="previousImage" variant="plain" class="rounded-full bg-gray-800 text-white w-12 h-12">
<v-icon size="36">mdi-chevron-left</v-icon>
</v-btn>
</div>
<div class="flex items-center justify-center w-full h-full z-10 overflow-hidden relative">
<img
:src="currentImage"
:style="imageStyle"
class="image-content"
v-if="isImage(currentImage)"
alt="Image"
/>
<component
v-else
:is="getComponent(currentImage)"
:src="currentImage"
class="video-content"
/>
</div>
<div v-if="multipleImages" class="absolute right-4 top-1/2 transform -translate-y-1/2 z-10">
<v-btn @click="nextImage" variant="plain" class="rounded-full bg-gray-800 text-white w-12 h-12">
<v-icon size="36">mdi-chevron-right</v-icon>
</v-btn>
</div>
</div>
<div class="fixed-width border-l-2 p-6 bg-white overflow-y-auto max-h-screen">
<div class="border-b-2 p-6 font-sans space-y-2">
<div class="flex flex-row align-center" v-if="data && data.createdByName">
<img :src="data.createdByPortraitUrl" class="rounded-full w-9" alt="">
<p class="ml-2 capitalize">{{ data.createdByName }}</p>
</div>
<div v-if="data && data.title" class="font-semibold">{{ data.title }}</div>
<div v-if="data && data.description">{{ data.description }}</div>
<div class="flex justify-around py-2">
<Reaction v-if="data" :content="data"></Reaction>
<donation-button v-if="data"></donation-button>
</div>
</div>
<div class="border-b-2 p-6">
<h2 class="font-sans font-semibold">Commentaires</h2>
<message-list :subject-id="contentId" :messages="messages"></message-list>
</div>
<div class="border-b-2 p-6">
<post-message :subject-id="contentId" @message-posted="addMessage"></post-message>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, onBeforeUnmount } from 'vue';
import PostMessage from "@/views/messages/PostMessage.vue";
import MessageList from "@/views/messages/MessageList.vue";
import DonationButton from "@/views/creators/DonationButton.vue";
import YoutubePlayer from '../YoutubePlayer.vue';
import ImageViewer from '../ImageViewer.vue';
import { useClient } from "@/plugins/api.js";
import { useRoute } from 'vue-router';
import Reaction from "@/views/contents/Reaction.vue";
import { useMessageStore } from "@/stores/messageStore.js";
const data = ref(null);
const currentImageIndex = ref(0);
const route = useRoute();
const client = useClient();
const messageStore = useMessageStore();
const contentId = computed(() => route.params.contentId);
const messages = ref([]);
const messageCount = ref(0);
const messagesVisible = ref(false);
const currentImage = computed(() => {
if (data.value && data.value.urls) {
return data.value.urls[currentImageIndex.value] || '';
}
return '';
});
const multipleImages = computed(() => data.value?.urls.length > 1);
const containerRef = ref(null);
const containerWidth = ref(0);
const containerHeight = ref(0);
function updateContainerDimensions() {
if (containerRef.value) {
containerWidth.value = containerRef.value.offsetWidth;
containerHeight.value = containerRef.value.offsetHeight;
}
}
onMounted(() => {
updateContainerDimensions();
window.addEventListener('resize', updateContainerDimensions);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', updateContainerDimensions);
});
const imageStyle = computed(() => {
return {
maxWidth: `${containerWidth.value}px`,
maxHeight: `${containerHeight.value}px`,
objectFit: 'contain',
};
});
function isImage(url) {
return url.match(/\.(jpeg|jpg|gif|png)$/);
}
function getComponent(url) {
if (url.includes('youtube.com') || url.includes('youtu.be')) {
return YoutubePlayer;
} else if (url.match(/\.(jpeg|jpg|gif|png)$/)) {
return ImageViewer;
}
return 'div';
}
const fetchContentData = async (contentId) => {
try {
const response = await client.get(`/api/contents/${contentId}`);
data.value = response.data;
} catch (error) {
console.error(`Error fetching content: ${error}`);
}
};
function goBack() {
window.history.go(-1);
}
function nextImage() {
if (data.value?.urls.length > 0) {
currentImageIndex.value = (currentImageIndex.value + 1) % data.value.urls.length;
}
}
function previousImage() {
if (data.value?.urls.length > 0) {
currentImageIndex.value = (currentImageIndex.value - 1 + data.value.urls.length) % data.value.urls.length;
}
}
function toggleComments() {
messagesVisible.value = !messagesVisible.value;
}
function addMessage(newMessage) {
messages.value.unshift(newMessage);
messagesVisible.value = true;
messageCount.value++;
}
onMounted(async () => {
await fetchContentData(contentId.value);
messageCount.value = await messageStore.fetchMessageCount(contentId.value);
messages.value = await messageStore.fetchMessages(contentId.value);
});
watch(contentId, async (newContentId) => {
await fetchContentData(newContentId);
messageCount.value = await messageStore.fetchMessageCount(newContentId);
messages.value = await messageStore.fetchMessages(newContentId);
});
</script>
<style scoped>
.fixed-width {
width: 400px;
}
.v-btn {
transition: background-color 0.2s ease;
}
.v-btn:hover {
background-color: #555;
}
.v-btn .v-icon {
color: white;
}
</style>

View File

@@ -0,0 +1,164 @@
<template>
<div class="flex flex-col -mt-2">
<!-- Titre en haut de l'image -->
<div class="bg-white py-4 text-center font-semibold text-lg">
<div v-if="data && data.title">
{{ data.title }}
</div>
</div>
<!-- Homemade carousel -->
<div class="flex-1 flex items-center justify-center bg-neutral-500 max-h-screen relative">
<!-- Blur image BG (désactivation des interactions) -->
<div class="absolute inset-0 z-0 bg-cover bg-center blur-lg pointer-events-none"
:style="{ backgroundImage: `url(${currentImage})` }"></div>
<!-- back Btn -->
<div class="absolute top-8 left-4 z-20">
<v-btn @click="goBack" variant="plain"
class="rounded-full text-white w-12 h-12 flex items-center justify-center">
<v-icon class="text-black bg-white rounded-full" size="36">mdi-close</v-icon>
</v-btn>
</div>
<!-- Left arrow collée à gauche -->
<div v-if="multipleImages" class="absolute left-0 top-1/2 transform -translate-y-1/2 z-20">
<v-btn @click="previousImage" variant="plain" class="rounded-full bg-gray-800 text-white w-12 h-12">
<v-icon size="36">mdi-chevron-left</v-icon>
</v-btn>
</div>
<div class="flex items-center justify-center w-full h-full z-10">
<img :src="currentImage" alt="Image" class="max-w-full max-h-full object-contain"/>
</div>
<!-- right arrow -->
<div v-if="multipleImages" class="absolute right-4 top-1/2 transform -translate-y-1/2 z-10">
<v-btn @click="nextImage" variant="plain" class="rounded-full bg-gray-800 text-white w-12 h-12">
<v-icon size="36">mdi-chevron-right</v-icon>
</v-btn>
</div>
</div>
<!-- Info -->
<div class="flex flex-col p-6 bg-white overflow-y-auto max-h-screen">
<div class="border-b-2 p-6 font-sans space-y-2">
<div class="flex flex-row align-center" v-if="data && data.createdByName">
<img :src="data.createdByPortraitUrl" class="rounded-full w-9" alt="">
<p class="ml-2 capitalize ">{{ data.createdByName }}</p>
</div>
<div v-if="data && data.description">
Description: {{ data.description }}
</div>
<div v-if="data" class="flex justify-around py-2">
<reaction :content="data"></reaction>
<donation-button></donation-button>
</div>
</div>
<div class="border-b-2 p-6">
<h2 class="font-sans font-semibold">Commentaires</h2>
<message-list :subject-id="contentId"
></message-list>
</div>
<div class="border-b-2 p-6">
<post-message :subject-id="contentId"
></post-message>
</div>
</div>
</div>
</template>
<script setup>
import {ref, computed, onMounted, watch} from 'vue';
import PostMessage from "@/views/messages/PostMessage.vue";
import MessageList from "@/views/messages/MessageList.vue";
import DonationButton from "@/views/creators/DonationButton.vue";
import {useClient} from "@/plugins/api.js";
import {useRoute} from 'vue-router';
import Reaction from "@/views/contents/Reaction.vue";
const data = ref(null);
const currentImageIndex = ref(0);
const route = useRoute();
const client = useClient();
const contentId = computed(() => {
return route.params.contentId;
});
const currentImage = computed(() => {
if (data.value && data.value.urls) {
return data.value.urls[currentImageIndex.value] || '';
}
return '';
});
// Calculer si on a plus d'une image
const multipleImages = computed(() => {
if (data.value && data.value.urls) {
return data.value.urls.length > 1;
}
return 0;
});
const fetchContentData = async (contentId) => {
try {
const response = await client.get(`/api/contents/${contentId}`);
data.value = response.data;
} catch (error) {
console.error(`Error fetching content: ${error}`);
}
};
function goBack() {
window.history.go(-1);
}
function nextImage() {
if (data.value?.urls.length > 0) {
currentImageIndex.value = (currentImageIndex.value + 1) % data.value.urls.length;
}
}
function previousImage() {
if (data.value?.urls.length > 0) {
currentImageIndex.value = (currentImageIndex.value - 1 + data.value.urls.length) % data.value.urls.length;
}
}
onMounted(() => {
fetchContentData(contentId.value);
});
watch(contentId, (newContentId) => {
fetchContentData(newContentId);
});
</script>
<style scoped>
.fixed-width {
width: 100%;
}
.v-btn {
transition: background-color 0.2s ease;
}
.v-btn:hover {
background-color: #555;
}
.v-btn .v-icon {
color: white;
}
</style>

View File

@@ -0,0 +1,285 @@
<script setup>
import { useClient } from '@/plugins/api.js';
import { useBrandingStore } from '@/stores/brandingStore.js';
import DonationButtonBanner from '@/views/creators/DonationButtonBanner.vue';
import { onBeforeUnmount, onMounted, ref } from 'vue';
import IconAccountVerified from "@/components/icons/IconAccountVerified.vue";
const brandingStore = useBrandingStore();
const isMobile = ref(false);
const creator = ref(null);
const baseURL = window.location.origin;
const creatorName = window.location.pathname.split('/@').pop();
function updateIsMobile() {
isMobile.value = window.innerWidth <= 640;
}
// Récupération des URLs des réseaux sociaux
function GetSocialsUrls() {
const socials = [];
const brandingSocials = brandingStore.value.socials;
if (brandingSocials.facebookUrl) {
socials.push({
icon: 'mdi-facebook',
url: brandingSocials.facebookUrl,
});
}
if (brandingSocials.instagramUrl) {
socials.push({
icon: 'mdi-instagram',
url: brandingSocials.instagramUrl,
});
}
if (brandingSocials.xUrl) {
socials.push({
icon: 'mdi-twitter',
url: brandingSocials.xUrl,
});
}
if (brandingSocials.linkedInUrl) {
socials.push({
icon: 'mdi-linkedin',
url: brandingSocials.linkedInUrl,
});
}
if (brandingSocials.tikTokUrl) {
socials.push({
icon: '/images/socials/tiktok-white.png',
url: brandingSocials.tikTokUrl,
});
}
if (brandingSocials.youtubeUrl) {
socials.push({
icon: 'mdi-youtube',
url: brandingSocials.youtubeUrl,
});
}
if (brandingSocials.redditUrl) {
socials.push({
icon: 'mdi-reddit',
url: brandingSocials.redditUrl,
});
}
if (brandingSocials.websiteUrl) {
socials.push({
icon: 'mdi-web',
url: brandingSocials.websiteUrl,
});
}
return socials;
}
const isSticky = ref(false);
const mainContainer = ref(null);
onMounted(async () => {
updateIsMobile();
window.addEventListener('resize', updateIsMobile);
const observer = new IntersectionObserver(
([entry]) => {
isSticky.value = !entry.isIntersecting;
},
{ threshold: 0 }
);
if (mainContainer.value) {
observer.observe(mainContainer.value);
}
const client = useClient();
try {
const creatorResponse = await client.get(`/api/creators/@${creatorName}`);
creator.value = creatorResponse.data;
} catch (error) {
creator.value = undefined;
}
});
onBeforeUnmount(() => {
window.removeEventListener('resize', updateIsMobile);
});
</script>
<template>
<div class="flex flex-column w-full">
<!-- Container principal avec le profil -->
<div class="relative w-full shadow-xl rounded-2xl">
<div
ref="mainContainer"
class="rounded-b-2xl shadow-2xl"
:style="{
backgroundColor: brandingStore.colors.primary,
boxShadow: '0 5px 10px rgba(0, 0, 0, 0.3)',
}"
>
<div>
<!-- Profile et Info -->
<div>
<!-- Version PC -->
<div v-show="!isMobile" class="items-start">
<div>
<img
class="shadow-2xl rounded-full border-solid border-102 absolute z-20 max-w-[190px] ml-10 -mt-5"
:src="
brandingStore.value.images.logo
? brandingStore.value.images.logo
: '/images/placeholders/logo.png'
"
alt="Profile Picture"
:style="{
borderColor: brandingStore.colors.secondary,
height: '190px',
}"
/>
</div>
<div
class="ml-64 w-25 min-w-60 flex flex-row"
:style="{ color: brandingStore.colors.onPrimary }"
>
<div v-show="brandingStore.value.verified" class="text-blue m-4 align-content-center verifiedhook">
<icon-account-verified></icon-account-verified>
</div>
<div class="flex flex-col">
<span class="capitalize text-3xl titlepos">
{{ brandingStore.value.name }}
</span>
<span class="capitalize text-lg titlepos">
{{ brandingStore.value.title }}
</span>
</div>
</div>
</div>
<!-- Version Mobile -->
<div class="relative">
<div
:style="{
borderColor: brandingStore.colors.secondary,
height: '80px',
}"
>
<div
v-show="isMobile"
class="absolute -top-7 left-0 px-3 flex flex-row items-center z-30"
>
<div>
<img
class="shadow-2xl rounded-full border-solid z-20 max-w-[150px]"
:src="
brandingStore.value.images.logo
? brandingStore.value.images.logo
: '/images/placeholders/logo.png'
"
alt="Profile Picture"
:style="{ height: '135px' }"
/>
</div>
<div v-show="brandingStore.value.verified" class="text-blue m-4 align-content-center">
<icon-account-verified></icon-account-verified>
</div>
<div class="ml-3 text-white w-full flex flex-col items-start">
<p class="capitalize text-2xl">
{{ brandingStore.value.name }}
</p>
<p class="capitalize text-md">
{{ brandingStore.value.title }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Actions - Follow et Register -->
<!-- <div class="flex flex-col items-center justify-center w-full">-->
<!-- <div class="flex flex-row space-x-1 justify-center mt-3 mb-2">-->
<!-- &lt;!&ndash;<subscribe-button></subscribe-button>&ndash;&gt;-->
<!-- </div>-->
<!-- </div>-->
</div>
<!-- Bouton Support -->
<div
v-show="brandingStore.value.acceptDonation"
class="z-20 shadow-2xl rounded-md text-white flex justify-center items-center z-50"
:class="{
'absolute bottom-6 right-8 w-64 h-28 ': !isMobile,
'fixed bottom-0 left-0 right-0 w-full h-16': isMobile,
}"
:style="{ backgroundColor: brandingStore.colors.secondary }"
>
<donation-button-banner
v-if="creator"
:creator-id="creator.id"
:creator-name="creator.name"
:on-success-url="baseURL + '/paymentcompleted/' + creator.id"
:on-cancelled-url="baseURL + '/paymentfailed/' + creator.id"
></donation-button-banner>
</div>
</div>
</div>
<!-- Section pour les icônes de réseaux sociaux -->
<div
class="rounded-b-2xl -mt-3 h-12 px-36 flex flex-col items-center justify-center"
:style="{
backgroundColor: brandingStore.colors.secondary,
boxShadow: '0 5px 20px rgba(0, 0, 0, 0.3)',
}"
>
<div class="flex justify-evenly mt-3 w-full">
<div class="flex flex-row space-x-6 justify-center">
<a
v-for="socialNetwork in GetSocialsUrls()"
:key="socialNetwork.url"
:href="socialNetwork.url"
target="_blank"
class="text-white text-md transform transition-transform duration-200 hover:scale-125 hover:text-blue-500"
>
<v-icon v-if="socialNetwork.icon.includes('mdi')">
{{ socialNetwork.icon }}
</v-icon>
<img
v-else
:src="socialNetwork.icon"
class="w-6 h-6 mt-0.5"
:alt="socialNetwork.url"
/>
</a>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.nav-button {
@apply rounded flex justify-center font-sans py-1 text-white tracking-widest p-4;
}
.nav-button:hover {
@apply bg-purple-800;
}
/* Transition CSS */
.transition-all {
transition: all 0.3s ease-in-out;
}
.titlepos {
position: relative;
top: 30px;
}
.verifiedhook{
position: relative;
top: 16px;
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<!-- PC -->
<div v-if="!isMobile">
<div class="shadow-lg rounded-2xl mt-2">
<div class="relative z-20">
<div class="min-h-8 rounded-t-2xl shadow-lg" :style="{ backgroundColor: branding.colors.primary }"></div>
<!-- Banner -->
<div class="relative">
<div>
<img
class="w-full drop-shadow-[0_10px_6px_rgba(0,0,0,0.25)]"
:src="branding.value.images.banner ? branding.value.images.banner : '/images/placeholders/banner.png'"
alt="Profile Banner"
style="max-height: 425px"
>
</div>
</div>
</div>
<banner-actions></banner-actions>
</div>
</div>
<!-- Mobile -->
<div v-if="isMobile">
<div class="shadow-lg rounded-2xl ">
<div class="relative z-20">
<div class="shadow-2xl flex items-center px-2 py-2"
:style="{ backgroundColor: branding.colors.primary, color: branding.colors.onPrimary }">
<router-link to="/@Hutopy">
<div class="flex items-center">
<HutopySvg></HutopySvg>
<div class="text-xl font-bold -ml-2 ">Hutopy</div>
</div>
</router-link>
<div class="flex-1"></div>
<router-link to="/login">
<button class="lg:hidden flex items-center justify-center mr-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
:stroke="branding.colors.onPrimary" class="w-8 h-8">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</router-link>
</div>
<!-- Banner -->
<div class="relative">
<div>
<img
class="w-full drop-shadow-[0_10px_6px_rgba(0,0,0,0.25)]"
:src="branding.value.images.banner ? branding.value.images.banner : '/images/placeholders/banner.png'"
alt="Profile Banner"
style="max-height: 425px"
>
</div>
</div>
</div>
<banner-actions></banner-actions>
</div>
</div>
</template>
<script setup>
import {ref, onMounted, onBeforeUnmount} from "vue";
import BannerActions from "@/views/creators/BannerActions.vue";
import {useBrandingStore} from "@/stores/brandingStore.js";
import HutopySvg from "@/views/svg/HutopySvg.vue";
const branding = useBrandingStore();
const isMobile = ref(false);
function updateIsMobile() {
isMobile.value = window.innerWidth <= 640;
}
onMounted(() => {
updateIsMobile();
window.addEventListener("resize", updateIsMobile);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", updateIsMobile);
});
</script>
<style>
</style>

View File

@@ -0,0 +1,28 @@
<template>
<div class="w-full h-full">
<div v-if="brandingStore.value.loading">
<v-progress-linear indeterminate></v-progress-linear>
</div>
<div v-else>
<content-list :creator-id="brandingStore.value.id"
></content-list>
</div>
</div>
</template>
<script async setup>
import {useBrandingStore} from "@/stores/brandingStore.js";
import ContentList from "@/views/contents/ContentList.vue";
import {useRouter} from "vue-router";
const brandingStore = useBrandingStore()
const router = useRouter();
const createHtmlContent = () => {
router.push('/content/editor');
}
const createContent = () => {
router.push('/content/post');
}
</script>

View File

@@ -0,0 +1,634 @@
<template>
<div v-if="creatorProfileStore.creator.id === brandingStore.value.id" class="flex justify-end space-x-2 mb-5 pa-1">
<!-- Bouton principal : Éditer ou Enregistrer -->
<button
v-if="isLoggedIn"
@click="isEditMode ? saveChanges() : toggleEditMode()"
class="px-4 py-2 rounded-md hover:opacity-90"
:style="{ backgroundColor: brandingStore.colors.secondary, color: brandingStore.colors.onSecondary }"
>
{{ isEditMode ? 'Enregistrer' : 'Éditer' }}
</button>
<button
v-if="isEditMode && isLoggedIn"
@click="cancelEdit"
class="px-4 py-2 rounded-md hover:opacity-90 bg-red-500 text-white"
>
Annuler
</button>
</div>
<div class="flex flex-col space-y-8 px-6 rounded-2xl py-8 shadow-2xl"
:style="{ backgroundColor: brandingStore.colors.primary, color: brandingStore.colors.onPrimary }">
<!-- Titre principal -->
<div v-if="isEditMode">
<div class="text-2xl py-2"> Titre</div>
<textarea v-model="editableMainTitle" class="w-full p-2 border rounded-md h-24"
:style="{ backgroundColor: brandingStore.colors.secondary, color: brandingStore.colors.onSecondary }"></textarea>
</div>
<h1 v-else-if="mainTitle" class="text-4xl font-bold text-center ">{{ mainTitle }}</h1>
<!-- Image principale -->
<div class="relative flex justify-center">
<label v-if="isEditMode">
<input type="file" @change="updateImage('mainImageUrl', $event)" class="hidden"/>
<img :src="mainImageUrl || fallbackImage"
alt="Image principale"
class="rounded-md max-w-full h-auto cursor-pointer"/>
</label>
<img v-else-if="mainImageUrl" :src="mainImageUrl"
alt="Image principale"
class="rounded-md max-w-full h-auto cursor-pointer"
@click="openFullscreen(mainImageUrl)"/>
<button v-if="isEditMode" @click="deleteImage('mainImageUrl')"
class="absolute top-10 right-2 px-2 py-1 bg-red-500 text-white hover:bg-red-600">
X
</button>
</div>
<!-- Texte sous l'image principale -->
<div v-if="isEditMode">
<div class="text-2xl py-2"> Description</div>
<textarea v-model="editableMainImageText" class="w-full p-2 border rounded-md h-24"
:style="{ backgroundColor: brandingStore.colors.secondary, color: brandingStore.colors.onSecondary }"></textarea>
</div>
<p v-else-if="mainImageText" class="text-lg text-justify">
{{ mainImageText }}
</p>
<!-- Titre video principale -->
<div v-if="isEditMode">
<div class="text-2xl py-2"> Titre Vidéo Princpiale</div>
<textarea v-model="editableVideoSubtitleMain" class="w-full p-2 border rounded-md h-24"
:style="{ backgroundColor: brandingStore.colors.secondary, color: brandingStore.colors.onSecondary }"></textarea>
</div>
<h2 v-else-if="videoSubtitleMain" class="text-2xl font-semibold text-center" >
{{ videoSubtitleMain }}
</h2>
<!-- Vidéo YouTube principale -->
<div v-if="isEditMode">
<div class="text-2xl py-2">URL vidéo</div>
<input
v-if="isEditMode"
type="text"
v-model="editableVideoUrlMain"
class="w-full p-2 border rounded-md"
:style="{ backgroundColor: brandingStore.colors.secondary, color: brandingStore.colors.onSecondary }"
/>
</div>
<div class="flex justify-center">
<div v-if="isEditMode"></div>
<div v-else-if="videoUrlMain" class="video-container">
<iframe
:src="videoUrlMain"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
class="video-frame">
</iframe>
</div>
</div>
<!-- Texte sous video principale -->
<div v-if="isEditMode">
<div class="text-2xl py-2"> Description</div>
<textarea v-model="editableMainVideoText" class="w-full p-2 border rounded-md h-24"
:style="{ backgroundColor: brandingStore.colors.secondary, color: brandingStore.colors.onSecondary }"></textarea>
</div>
<p v-else-if="mainVideoText" class="text-lg text-justify">
{{ mainVideoText }}
</p>
<!-- Sous-titre avant les deux images -->
<div v-if="isEditMode">
<div v-if="isEditMode" class="text-2xl py-2"> Sous-titre</div>
<textarea v-model="editableImagesSubtitle" class="w-full p-2 border rounded-md h-24"
:style="{ backgroundColor: brandingStore.colors.secondary, color: brandingStore.colors.onSecondary }"></textarea>
</div>
<h2 v-else-if="imagesSubtitle" class="text-2xl font-semibold text-center">
{{ imagesSubtitle }}
</h2>
<!-- 4 images côte à côte -->
<FullscreenImage ref="fullscreenImage" :image-url="currentImage" />
<div>
<!-- Mode édition -->
<div v-if="isEditMode">
<div class="text-2xl py-2">Images</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
<!-- Première image -->
<div class="relative">
<label>
<input type="file" @change="updateImage('image1Url', $event)" class="hidden" />
<img :src="image1Url || fallbackImage"
alt="Image 1"
class="rounded-md max-w-full h-auto cursor-pointer" />
</label>
<button @click="deleteImage('image1Url')"
class="absolute top-2 right-2 px-2 py-1 bg-red-500 text-white hover:bg-red-600">
X
</button>
</div>
<!-- Deuxième image -->
<div class="relative">
<label>
<input type="file" @change="updateImage('image2Url', $event)" class="hidden" />
<img :src="image2Url || fallbackImage"
alt="Image 2"
class="rounded-md max-w-full h-auto cursor-pointer" />
</label>
<button @click="deleteImage('image2Url')"
class="absolute top-2 right-2 px-2 py-1 bg-red-500 text-white hover:bg-red-600">
X
</button>
</div>
<!-- Troisième image -->
<div class="relative">
<label>
<input type="file" @change="updateImage('image3Url', $event)" class="hidden" />
<img :src="image3Url || fallbackImage"
alt="Image 3"
class="rounded-md max-w-full h-auto cursor-pointer" />
</label>
<button @click="deleteImage('image3Url')"
class="absolute top-2 right-2 px-2 py-1 bg-red-500 text-white hover:bg-red-600">
X
</button>
</div>
<!-- Quatrième image -->
<div class="relative">
<label>
<input type="file" @change="updateImage('image4Url', $event)" class="hidden" />
<img :src="image4Url || fallbackImage"
alt="Image 4"
class="rounded-md max-w-full h-auto cursor-pointer" />
</label>
<button @click="deleteImage('image4Url')"
class="absolute top-2 right-2 px-2 py-1 bg-red-500 text-white hover:bg-red-600">
X
</button>
</div>
</div>
</div>
<!-- Mode normal -->
<div v-else>
<div class="flex flex-wrap gap-4">
<!-- Première image -->
<div class="relative w-full sm:flex-1 sm:min-w-[calc(25%-1rem)]" v-if="image1Url" @click="openFullscreen(image1Url)">
<img :src="image1Url" alt="Image 1" class="rounded-md max-w-full h-auto cursor-pointer" />
</div>
<!-- Deuxième image -->
<div class="relative w-full sm:flex-1 sm:min-w-[calc(25%-1rem)]" v-if="image2Url" @click="openFullscreen(image2Url)">
<img :src="image2Url" alt="Image 2" class="rounded-md max-w-full h-auto cursor-pointer" />
</div>
<!-- Troisième image -->
<div class="relative w-full sm:flex-1 sm:min-w-[calc(25%-1rem)]" v-if="image3Url" @click="openFullscreen(image3Url)">
<img :src="image3Url" alt="Image 3" class="rounded-md max-w-full h-auto cursor-pointer" />
</div>
<!-- Quatrième image -->
<div class="relative w-full sm:flex-1 sm:min-w-[calc(25%-1rem)]" v-if="image4Url" @click="openFullscreen(image4Url)">
<img :src="image4Url" alt="Image 4" class="rounded-md max-w-full h-auto cursor-pointer" />
</div>
</div>
</div>
</div>
<!-- Texte sous les deux images -->
<div v-if="isEditMode">
<div class="text-2xl py-2"> Images</div>
<textarea v-model="editableImagesText" class="w-full p-2 border rounded-md h-24"
:style="{ backgroundColor: brandingStore.colors.secondary, color: brandingStore.colors.onSecondary }"></textarea>
</div>
<p v-else-if="imagesText" class="text-lg text-justify">
{{ imagesText }}
</p>
<!-- Sous-titre avant la vidéo -->
<div v-if="isEditMode">
<div class="text-2xl py-2"> Titre Video</div>
<textarea v-model="editableVideoSubtitle" class="w-full p-2 border rounded-md h-24"
:style="{ backgroundColor: brandingStore.colors.secondary, color: brandingStore.colors.onSecondary }"></textarea>
</div>
<h2 v-else-if="videoSubtitle" class="text-2xl font-semibold text-center">
{{ videoSubtitle }}
</h2>
<!-- Vidéo YouTube -->
<div v-if="isEditMode">
<div class="text-2xl py-2">URL vidéo</div>
<input
v-if="isEditMode"
type="text"
v-model="editableVideoUrl"
class="w-full p-2 border rounded-md"
:style="{ backgroundColor: brandingStore.colors.secondary, color: brandingStore.colors.onSecondary }"
/>
</div>
<div class="flex justify-center">
<div v-if="isEditMode"></div>
<iframe
v-else-if="videoUrl"
:src="videoUrl"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
class="rounded-md"
style="width: 600px; height: 337px;"
></iframe>
</div>
<!-- Texte sous la vidéo -->
<div v-if="isEditMode" class="text-2xl"> Description</div>
<div v-if="isEditMode">
<textarea v-model="editableVideoText" class="w-full p-2 border rounded-md h-24"
:style="{ backgroundColor: brandingStore.colors.secondary, color: brandingStore.colors.onSecondary }"></textarea>
</div>
<p v-else-if="videoText" class="text-lg text-justify">
{{ videoText }}
</p>
<!-- Informations de contact -->
<div class="flex flex-col space-y-6 mt-8">
<div v-if="isEditMode" class="flex flex-col space-y-2">
<!-- Édition du téléphone -->
<div>
<label class="text-lg">Numéro de téléphone</label>
<input
v-model="editablePhoneNumber"
type="text"
class="w-full p-2 border rounded-md"
:style="{ backgroundColor: brandingStore.colors.secondary, color: brandingStore.colors.onSecondary }"
/>
</div>
<!-- Édition de l'email -->
<div>
<label class="text-lg">Adresse email</label>
<input
v-model="editableEmail"
type="text"
class="w-full p-2 border rounded-md"
:style="{ backgroundColor: brandingStore.colors.secondary, color: brandingStore.colors.onSecondary }"
/>
</div>
</div>
<div
v-else
class="flex flex-col sm:flex-row sm:space-x-64 space-y-4 sm:space-y-0 justify-center items-center"
>
<!-- Affichage du téléphone -->
<div v-if="editablePhoneNumber" class="flex items-center space-x-2">
<i class="mdi mdi-phone-outline text-2xl"></i>
<span>{{ editablePhoneNumber }}</span>
</div>
<!-- Affichage de l'email -->
<div v-if="editableEmail" class="flex items-center space-x-2">
<i class="mdi mdi-email-outline text-2xl"></i>
<a
:href="`mailto:${editableEmail}`"
class="no-underline text-current"
>
{{ editableEmail }}
</a>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {ref, onMounted} from "vue";
import { useClient } from "@/plugins/api.js";
import {useBrandingStore} from "@/stores/brandingStore.js";
import { useCreatorProfileStore } from "@/stores/creatorProfileStore.js";
import { useDisplay } from "vuetify";
import { watch} from "vue";
import FullscreenImage from "@/views/creators/FullscreenImage.vue";
const { smAndDown } = useDisplay();
const isMobileView = ref(smAndDown.value);
const creatorProfileStore = useCreatorProfileStore();
const brandingStore = useBrandingStore();
const client = useClient();
const isLoading = ref(true);
const isLoggedIn = true;
const isEditMode = ref(false);
const currentImage = ref("");
const fullscreenImage = ref(null);
function openFullscreen(imageUrl) {
currentImage.value = imageUrl;
fullscreenImage.value.open();
}
watch(smAndDown, (newVal) => {
isMobileView.value = newVal;
});
// Image de fallback pour l'éditeur
const fallbackImage = "https://via.placeholder.com/300?text=Image+non+disponible";
// Variables réactives pour les données
const editablePhoneNumber = ref("");
const editableEmail = ref("");
const mainTitle = ref("");
const mainImageUrl = ref("");
const mainImageText = ref("");
const mainVideoText = ref("");
const imagesSubtitle = ref("");
const image1Url = ref("");
const image2Url = ref("");
const image3Url = ref("");
const image4Url = ref("");
const imagesText = ref("");
const videoSubtitle = ref("");
const videoSubtitleMain = ref("");
const videoUrlMain = ref("");
const videoUrl = ref("");
const videoText = ref("");
const phoneNumber = ref("");
const email = ref("");
const editableImages = ref([null, null, null, null]);
// Editable fields
const editableMainTitle = ref("");
const editableMainImageText = ref("");
const editableMainVideoText = ref("");
const editableImagesSubtitle = ref("");
const editableImagesText = ref("");
const editableVideoSubtitle = ref("");
const editableVideoSubtitleMain = ref("");
const editableVideoText = ref("");
const editableVideoUrlMain = ref("");
const editableVideoUrl = ref("");
// Activer/désactiver le mode édition
function toggleEditMode() {
isEditMode.value = !isEditMode.value;
if (isEditMode.value) {
// Charger les valeurs pour l'édition
editableMainTitle.value = mainTitle.value;
editableMainImageText.value = mainImageText.value;
editableMainVideoText.value = mainVideoText.value;
editableImagesSubtitle.value = imagesSubtitle.value;
editableImagesText.value = imagesText.value;
editableVideoSubtitle.value = videoSubtitle.value;
editableVideoSubtitleMain.value = videoSubtitleMain.value;
editableVideoText.value = videoText.value;
editableVideoUrlMain.value = videoUrlMain.value;
editableVideoUrl.value = videoUrl.value;
editablePhoneNumber.value = phoneNumber.value;
editableEmail.value = email.value;
} else {
// Sauvegarder les modifications ou réinitialiser les URLs des images supprimées
mainTitle.value = editableMainTitle.value;
mainImageText.value = editableMainImageText.value;
mainVideoText.value = editableMainVideoText.value;
imagesSubtitle.value = editableImagesSubtitle.value;
imagesText.value = editableImagesText.value;
videoSubtitle.value = editableVideoSubtitle.value;
videoSubtitleMain.value = editableVideoSubtitleMain.value;
videoText.value = editableVideoText.value;
videoUrlMain.value = editableVideoUrlMain.value;
videoUrl.value = editableVideoUrl.value;
phoneNumber.value = editablePhoneNumber.value;
email.value = editableEmail.value;
// Réinitialisation des images supprimées à des strings vides si nécessaire
if (mainImageUrl.value === null) mainImageUrl.value = "";
if (image1Url.value === null) image1Url.value = "";
if (image2Url.value === null) image2Url.value = "";
if (image3Url.value === null) image3Url.value = "";
if (image4Url.value === null) image4Url.value = "";
}
}
// Supprimer une image
function deleteImage(field) {
switch (field) {
case "mainImageUrl":
mainImageUrl.value = ""; // Remplace par un string vide
break;
case "image1Url":
image1Url.value = ""; // Remplace par un string vide
break;
case "image2Url":
image2Url.value = ""; // Remplace par un string vide
break;
case "image3Url":
image3Url.value = ""; // Remplace par un string vide
break;
case "image4Url":
image4Url.value = ""; // Remplace par un string vide
break;
}
}
// Mettre à jour une image
function updateImage(field, event) {
const file = event.target.files[0];
if (file) {
// Stocker le fichier dans editableImages pour l'envoi
switch (field) {
case "mainImageUrl":
editableImages.value[0] = file;
mainImageUrl.value = URL.createObjectURL(file);
break;
case "image1Url":
editableImages.value[1] = file;
image1Url.value = URL.createObjectURL(file);
break;
case "image2Url":
editableImages.value[2] = file;
image2Url.value = URL.createObjectURL(file);
break;
case "image3Url":
editableImages.value[3] = file;
image3Url.value = URL.createObjectURL(file);
break;
case "image4Url":
editableImages.value[4] = file;
image4Url.value = URL.createObjectURL(file);
break;
}
}
}
// Charger les données au montage
onMounted(() => {
mainTitle.value = brandingStore.presentationInfos.title;
mainImageUrl.value = brandingStore.presentationInfos.mainImageUrl;
mainImageText.value = brandingStore.presentationInfos.mainImageText;
mainVideoText.value = brandingStore.presentationInfos.mainVideoText;
imagesSubtitle.value = brandingStore.presentationInfos.imagesSubtitle;
image1Url.value = brandingStore.presentationInfos.image1Url;
image2Url.value = brandingStore.presentationInfos.image2Url;
image3Url.value = brandingStore.presentationInfos.image3Url;
image4Url.value = brandingStore.presentationInfos.image4Url;
imagesText.value = brandingStore.presentationInfos.imagesText;
videoSubtitle.value = brandingStore.presentationInfos.videoSubtitle;
videoSubtitleMain.value = brandingStore.presentationInfos.videoSubtitleMain;
videoUrl.value = brandingStore.presentationInfos.videoUrl;
videoUrlMain.value = brandingStore.presentationInfos.videoUrlMain;
videoText.value = brandingStore.presentationInfos.videoText;
editablePhoneNumber.value = brandingStore.presentationInfos.phoneNumber;
editableEmail.value= brandingStore.presentationInfos.email;
phoneNumber.value = brandingStore.presentationInfos.phoneNumber;
email.value = brandingStore.presentationInfos.email;
});
async function saveChanges() {
if (!creatorProfileStore.creator.id) {
console.error("L'ID du créateur est manquant !");
return;
}
const formData = new FormData();
// Ajout des champs textuels
formData.append("PhoneNumber", editablePhoneNumber.value || "");
formData.append("Email", editableEmail.value || "");
formData.append("Title", editableMainTitle.value || "");
formData.append("MainImageText", editableMainImageText.value || "");
formData.append("MainVideoText", editableMainVideoText.value || "");
formData.append("ImagesSubtitle", editableImagesSubtitle.value || "");
formData.append("ImagesText", editableImagesText.value || "");
formData.append("VideoSubtitle", editableVideoSubtitle.value || "");
formData.append("VideoSubtitleMain", editableVideoSubtitleMain.value || "");
formData.append("VideoUrlMain", editableVideoUrlMain.value || "");
formData.append("VideoUrl", editableVideoUrl.value || "");
formData.append("VideoText", editableVideoText.value || "");
// Ajout des URLs d'images supprimées
formData.append("MainImageUrl", mainImageUrl.value || ""); // Peut contenir un string vide
formData.append("Image1Url", image1Url.value || "");
formData.append("Image2Url", image2Url.value || "");
formData.append("Image3Url", image3Url.value || "");
formData.append("Image4Url", image4Url.value || "");
// Ajout des fichiers d'images téléversées
if (editableImages.value[0]) formData.append("MainImage", editableImages.value[0]);
if (editableImages.value[1]) formData.append("Image1", editableImages.value[1]);
if (editableImages.value[2]) formData.append("Image2", editableImages.value[2]);
if (editableImages.value[3]) formData.append("Image3", editableImages.value[3]);
if (editableImages.value[4]) formData.append("Image4", editableImages.value[4]);
try {
// Désactiver le bouton de sauvegarde pour éviter les clics multiples
isLoading.value = true;
// Envoyer les données au backend
const response = await client.post(
`/api/creators/${creatorProfileStore.creator.id}/presentation-infos`,
formData,
{ headers: { "Content-Type": "multipart/form-data" } }
);
// Mettre à jour les valeurs locales pour refléter les changements
mainTitle.value = editableMainTitle.value;
mainImageText.value = editableMainImageText.value;
mainVideoText.value = editableMainVideoText.value;
imagesSubtitle.value = editableImagesSubtitle.value;
imagesText.value = editableImagesText.value;
videoSubtitle.value = editableVideoSubtitle.value;
videoSubtitleMain.value = editableVideoSubtitleMain.value;
videoText.value = editableVideoText.value;
videoUrlMain.value = editableVideoUrlMain.value;
videoUrl.value = editableVideoUrl.value;
phoneNumber.value = editablePhoneNumber.value;
email.value = editableEmail.value;
console.log("Données sauvegardées :", response.data);
// Réinitialiser le mode édition
isEditMode.value = false;
// Rafraîchir après une légère pause pour s'assurer des mises à jour visuelles
} catch (error) {
console.error("Erreur lors de la sauvegarde :", error);
} finally {
// Réactiver les interactions
isLoading.value = false;
}
}
function cancelEdit() {
// Restaurer les valeurs d'origine
editableMainTitle.value = mainTitle.value;
editableMainImageText.value = mainImageText.value;
editableMainVideoText.value = mainVideoText.value;
editableImagesSubtitle.value = imagesSubtitle.value;
editableImagesText.value = imagesText.value;
editableVideoSubtitle.value = videoSubtitle.value;
editableVideoSubtitleMain.value = videoSubtitleMain.value;
editableVideoText.value = videoText.value;
editableVideoUrlMain.value = videoUrlMain.value;
editableVideoUrl.value = videoUrl.value;
editablePhoneNumber.value = phoneNumber.value;
editableEmail.value = email.value;
// Désactiver le mode édition
isEditMode.value = false;
}
</script>
<style scoped>
.video-container {
position: relative;
width: 100%;
padding-top: 56.25%; /* Ratio 16:9 (9/16 = 0.5625) */
}
.video-frame {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
border-radius: 0.5rem; /* Pour les bords arrondis */
}
</style>

View File

@@ -0,0 +1,27 @@
<template>
<div class="flex flex-col min-h-screen max-w-[1100px] mx-auto">
<div v-if="brandingStore.loading">
<v-progress-linear indeterminate></v-progress-linear>
</div>
<div v-else>
<creator-banner></creator-banner>
</div>
<div class="py-8 flex-grow">
<router-view></router-view>
</div>
<div>
<Footer></Footer>
</div>
</div>
</template>
<script async setup>
import CreatorBanner from "@/views/creators/CreatorBanner.vue";
import Footer from "@/views/main/Footer.vue";
import {useBrandingStore} from "@/stores/brandingStore.js";
const brandingStore = useBrandingStore()
</script>

View File

@@ -0,0 +1,165 @@
<template>
<v-btn class="hover:scale-125" variant="text" icon @click="openDonationDialog()">
<v-icon :class="['text-2xl', iconColorClass]">mdi-gift-outline</v-icon>
</v-btn>
<v-dialog v-model="donationModal" max-width="500">
<v-form>
<v-card class="text-center rounded-xl" :style="{ border: `3px solid ${brandingStore.colors.primary}` }">
<div class="py-4 text-2xl font-bold border-b mb-2">
Je Soutiens!
</div>
<div class="flex flex-row align-center px-3">
<img
:src="brandingStore.value.images.logo"
alt="Profile Image"
class="rounded-full"
width="40"
height="40"
:style="{ border: `2px solid ${brandingStore.colors.secondary}` }">
<div class="capitalize px-2 text-2xl">{{ brandingStore.value.name }}</div>
<v-btn icon @click="closeDonationDialog()" class="ml-auto" variant="text">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<v-card-text>
<v-text-field
v-model="tipAmountInDollars"
type="number"
:min="0"
class="p-2"
label="Montant"
density="comfortable"
variant="outlined"
hide-details
clearable
inputmode="numeric"
@keydown="preventNonNumeric"
prepend-inner-icon="mdi-currency-usd"
></v-text-field>
<v-textarea v-model="tipMessage"
label="Message (facultatif)"
class="p-2"
density="comfortable"
variant="outlined"
hide-details
clearable
></v-textarea>
<v-btn variant="outlined"
:style="{ borderColor: brandingStore.colors.primary, color: brandingStore.colors.primary }"
@click="goPay()" class="w-full mt-5">
Envoyez
</v-btn>
</v-card-text>
</v-card>
</v-form>
</v-dialog>
<v-dialog v-model="isPaymentDialogActive" max-width="720" persistent>
<template v-slot:default>
<v-card>
<div id="checkout">
</div>
<v-spacer></v-spacer>
<v-card-actions>
<v-btn block class="ma-auto"
style="width: 200px;"
@click="closeDialog()">Annuler
</v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</template>
<script setup>
import {useClient} from '@/plugins/api.js';
import {loadStripe} from '@stripe/stripe-js';
import {onMounted, ref} from 'vue';
import {useBrandingStore} from "@/stores/brandingStore.js";
const brandingStore = useBrandingStore()
const props = defineProps({
creatorId: {default: 'missing-creator-id', required: true},
creatorName: {default: 'missing-creator-name', required: true},
onSuccessUrl: {default: 'missing-on-success-u', required: true},
onCancelledUrl: {default: 'missing-on-cancelled-url', required: true},
iconColorClass: {default: 'text-black'}
});
const donationModal = ref(false);
function openDonationDialog() {
donationModal.value = true
}
function closeDonationDialog() {
donationModal.value = false
}
const isPaymentDialogActive = ref(false);
const tipAmountInDollars = ref(0);
const tipMessage = ref("");
let stripe = null;
let checkout;
onMounted(async () => {
stripe = await loadStripe(import.meta.env.VITE_STRIPE_API_KEY);
});
async function createCheckoutSession() {
const client = useClient()
let clientSecret = await client.post(
`/api/tips`,
{
amount: tipAmountInDollars.value * 100,
currency: 'CAD',
message: tipMessage.value,
creatorId: props.creatorId,
checkoutSuccessUrl: props.onSuccessUrl,
checkoutCancelledUrl: props.onCancelledUrl
});
return clientSecret.data;
}
function closeDialog() {
isPaymentDialogActive.value = false;
if (checkout) {
checkout.destroy();
}
}
async function goPay() {
isPaymentDialogActive.value = true;
const response = await createCheckoutSession()
// Redirect to the Stripe Checkout page
window.location.href = response.stripeCheckoutUrl
}
function preventNonNumeric(event) {
const key = event.key;
const allowedKeys = ['Backspace', 'ArrowLeft', 'ArrowRight', 'Delete'];
if (!/^\d$/.test(key) && !allowedKeys.includes(key)) {
event.preventDefault();
}
}
</script>

View File

@@ -0,0 +1,208 @@
<template>
<v-btn
variant="text"
style="font-size: x-large; height: 100%"
block
@click="openDonationDialog()"
>
{{ $t('isupportbtn.isupport') }}
</v-btn>
<v-dialog v-model="donationModal" max-width="500">
<v-form>
<v-card
class="text-center rounded-xl"
:style="{ border: `3px solid ${brandingStore.colors.primary}` }"
>
<div class="py-4 text-2xl font-bold border-b mb-2"> {{ $t('isupportbtn.isupport') }}</div>
<div class="flex flex-row align-center px-3">
<img
:src="brandingStore.value.images.logo"
alt="Profile Image"
class="rounded-full"
width="40"
height="40"
:style="{ border: `2px solid ${brandingStore.colors.secondary}` }"
/>
<div class="capitalize px-2 text-2xl">
{{ brandingStore.value.name }}
</div>
<v-btn
icon
@click="closeDonationDialog()"
class="ml-auto"
variant="text"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<v-card-text>
<v-text-field
v-model="tipAmountInDollars"
type="number"
autofocus
placeholder="0"
:min="0"
class="p-2"
:label="`${$t('isupportbtn.amount')}`"
density="comfortable"
variant="outlined"
hide-details
clearable
inputmode="numeric"
@keydown="preventNonNumeric"
prepend-inner-icon="mdi-currency-usd"
></v-text-field>
<v-textarea
v-model="tipMessage"
:label="`${$t('isupportbtn.message')}`"
class="p-2"
density="comfortable"
variant="outlined"
hide-details
clearable
></v-textarea>
<v-btn
variant="outlined"
:style="{
borderColor: brandingStore.colors.primary,
color: brandingStore.colors.primary,
backgroundColor: brandingStore.colors.secondary,
}"
@click="goPay()"
class="w-full mt-5"
>
{{ $t('isupportbtn.send') }}
</v-btn>
</v-card-text>
</v-card>
</v-form>
</v-dialog>
<v-dialog v-model="isPaymentDialogActive" max-width="720" persistent>
<template v-slot:default>
<v-card :style="{ padding: '20px' }">
<div id="checkout"></div>
<div v-if="errorMessage" class="error-message">{{ errorMessage }}</div>
<v-spacer></v-spacer>
<v-card-actions>
<v-btn
block
class="ma-auto"
style="width: 200px"
@click="closeDialog()"
>Annuler
</v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</template>
<script setup>
import { useClient } from '@/plugins/api.js';
import { useBrandingStore } from '@/stores/brandingStore.js';
import { loadStripe } from '@stripe/stripe-js';
import { onMounted, ref } from 'vue';
const brandingStore = useBrandingStore();
const props = defineProps({
creatorId: { default: 'missing-creator-id', required: true },
creatorName: { default: 'missing-creator-name', required: true },
onSuccessUrl: { default: 'missing-on-success-u', required: true },
onCancelledUrl: { default: 'missing-on-cancelled-url', required: true },
iconColorClass: { default: 'text-black' },
});
const errorMessage = ref('');
const donationModal = ref(false);
function openDonationDialog() {
donationModal.value = true;
}
function closeDonationDialog() {
donationModal.value = false;
}
const isPaymentDialogActive = ref(false);
const tipAmountInDollars = ref('');
const tipMessage = ref('');
let stripe = null;
let checkout;
onMounted(async () => {
stripe = await loadStripe(import.meta.env.VITE_STRIPE_API_KEY);
});
async function createCheckoutSession() {
const client = useClient();
try {
let clientSecret = await client.post(`/api/tips`, {
creatorId: props.creatorId,
amount: tipAmountInDollars.value * 100,
currency: 'CAD',
message: tipMessage.value,
checkoutSuccessUrl: props.onSuccessUrl,
checkoutCancelledUrl: props.onCancelledUrl,
});
return clientSecret.data;
} catch (error) {
console.error(error);
errorMessage.value = 'Une erreur est survenue. Veuillez réessayer.';
}
}
function closeDialog() {
isPaymentDialogActive.value = false;
errorMessage.value = '';
if (checkout) {
checkout.destroy();
}
}
async function goPay() {
isPaymentDialogActive.value = true;
const response = await createCheckoutSession();
// Redirect to the Stripe Checkout page
window.location.href = response.stripeCheckoutUrl;
}
function preventNonNumeric(event) {
const key = event.key;
const allowedKeys = ['Backspace', 'ArrowLeft', 'ArrowRight', 'Delete'];
if (!/^\d$/.test(key) && !allowedKeys.includes(key)) {
event.preventDefault();
}
}
</script>
<style>
.full-height {
height: 100%;
}
.error-message {
color: white;
background-color: red;
border-radius: 4px;
text-align: center;
width: 100%;
padding: 5px;
}
</style>

View File

@@ -0,0 +1,102 @@
<script setup>
import { useBrandingStore } from "@/stores/brandingStore.js";
import { ref } from "vue";
const branding = useBrandingStore();
const menu = ref(false); // C'est pour le menu déroulant!
// Fonction pour convertir une couleur hexadécimale en RGB afin d'appliquer la transparence avec nos couleurs du backend hex a rgb
function hexToRgb(hex) {
const bigint = parseInt(hex.replace('#', ''), 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return `${r}, ${g}, ${b}`;
}
</script>
<template>
<div class="flex items-center justify-center">
<!-- ExclusiveCard -->
<div
class="rounded-lg w-[290px] h-[380px] relative"
:style="{
backgroundColor: branding.colors.surface,
boxShadow: '0 10px 10px rgba(0, 0, 0, 0.3)',
borderColor: `rgba(${hexToRgb(branding.colors.secondary)}, 0.4)`,
borderWidth: '1px',
}"
>
<!-- Conteneur pour aligner le titre et le bouton -->
<div
class="flex items-center justify-between py-2 px-3"
:style="{ color: branding.colors.onPrimary }"
>
<div class="text-md">Comment créer un logo</div>
<!-- Bouton à trois points avec menu déroulant -->
<v-menu v-model="menu" activator="parent" offset-y>
<template #activator="{ props }">
<button
v-bind="props"
class="text-gray-600"
:style="{ color: branding.colors.onPrimary }"
>
<i class="mdi mdi-dots-vertical text-lg"></i>
</button>
</template>
<v-list
:style="{
backgroundColor: branding.colors.secondary,
color: branding.colors.onSecondary,
}"
>
<v-list-item title="Modifier" @click="modifier" />
<v-list-item title="Effacer" @click="effacer" />
<v-list-item title="Reporter" @click="reporter" />
</v-list>
</v-menu>
</div>
<div class="relative h-[170px] overflow-hidden">
<img
src="/images/hutopymedia/banners/hutopyul.png"
class="w-full h-full object-cover blur-md"
alt="image"
/>
<div class="absolute inset-0 flex items-center justify-center">
<i
class="mdi mdi-lock text-7xl p-2 rounded-full"
:style="{
color: branding.colors.secondary,
border: `2px solid ${branding.colors.secondary}`,
}"
></i>
</div>
</div>
<div class="text-end pa-2 px-4" :style="{ color: branding.colors.onPrimary }">
14-05-2024
</div>
<div class="text-justify px-4 text-md" :style="{ color: branding.colors.onPrimary }">
Tutoriel sur comment s'assurer d'avoir un logo unique et percutant
qui se démarque de la concurrence.
</div>
</div>
</div>
</template>
<style scoped></style>
<script>
function modifier() {
console.log("Modifier l'élément");
}
function effacer() {
console.log("Effacer l'élément");
}
function reporter() {
console.log("Reporter l'élément");
}
</script>

View File

@@ -0,0 +1,180 @@
<template>
<div class="overflow-hidden relative" @wheel="handleScroll">
<!-- Container that holds all the posts and permet le défilement -->
<div class="relative h-[1000px] max-h-[1000px] overflow-hidden p-4">
<div class="transition-transform duration-500" :style="{ transform: `translateY(-${scrollPosition}px)` }">
<!-- Grille avec colonnes dynamiques basées sur la largeur -->
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 min-w-[250px]">
<div v-for="(item, index) in contenuexclusif" :key="index"
class="my-1 text-white rounded-lg w-full border-2 shadow h-[380px] hover-card relative overflow-hidden"
:style="{
background: creator.colors.bannerTop,
borderColor: `rgba(${getRGB(creator.colors.bannerBottom)}, 0.38)`
}">
<div class="flex justify-center items-center">
</div>
<div>
<img :src="item.photo" class="w-full h-auto max-h-[170px] object-cover" />
<!-- Section du nombre de clics et du bouton d'édition -->
<div class="flex flex-row justify-between items-center p-2">
<div class="flex items-center">
<p class="text-xs">{{ item.date }}</p>
<p class="text-xs px-2">|</p>
<p class="text-xs">200 clicks</p>
</div>
<!-- Bouton pour éditer le contenu à droite -->
<v-btn class="" icon variant="plain" @click="editCard(item)">
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</div>
<p class="text-md p-4">{{ item.title }}</p>
<!-- Section des étoiles, fixée dans le coin inférieur droit -->
<div v-if="item.rating" class="stars flex justify-end p-2 absolute bottom-0 right-0">
<!-- Génération dynamique des étoiles -->
<span v-for="star in 5" :key="star" class="text-yellow-500">
<v-icon v-if="star <= item.rating">mdi-star</v-icon>
<v-icon v-else>mdi-star-outline</v-icon>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, defineProps } from 'vue';
function hexToRgb(hex) {
let shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, function(m, r, g, b) {
return r + r + g + g + b + b;
});
let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
function getRGB(hexColor) {
const rgb = hexToRgb(hexColor);
return `${rgb.r}, ${rgb.g}, ${rgb.b}`;
}
const contenuexclusif = ref([
{ title: 'Créer un site web moderne', description: 'Un guide pour concevoir un site qui attire l\'attention et se démarque.', photo: '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png', rating: 2, date: '2024-09-19' },
{ title: ' Les secrets dun logo réussiLes secrets dun logo réussiLes secrets dun logo réussi', description: 'Découvrez les astuces pour un logo mémorable et distinctif.Découvrez les astuces pour un logo mémorable et distinctif.', photo: '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png', rating: 4, date: '2024-09-19' },
{ title: 'Les secrets dun logo réussi', description: 'Découvrez les astuces pour un logo mémorable et distinctif.', photo: '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png', rating: 4, date: '2024-09-19' },
{ title: 'Les secrets dun logo réussi', description: 'Découvrez les astuces pour un logo mémorable et distinctif.', photo: '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png', rating: 4, date: '2024-09-19' },
{ title: 'Les secrets dun logo réussi', description: 'Découvrez les astuces pour un logo mémorable et distinctif.', photo: '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png', rating: 4, date: '2024-09-19' },
{ title: 'Les secrets dun logo réussi', description: 'Découvrez les astuces pour un logo mémorable et distinctif.', photo: '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png', rating: 4, date: '2024-09-19' },
{ title: 'Les secrets dun logo réussi', description: 'Découvrez les astuces pour un logo mémorable et distinctif.', photo: '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png', rating: 4, date: '2024-09-19' },
{ title: 'Les secrets dun logo réussi', description: 'Découvrez les astuces pour un logo mémorable et distinctif.', photo: '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png', rating: 4, date: '2024-09-19' },
{ title: 'Les secrets dun logo réussi', description: 'Découvrez les astuces pour un logo mémorable et distinctif.', photo: '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png', rating: 4, date: '2024-09-19' },
{ title: 'Les secrets dun logo réussi', description: 'Découvrez les astuces pour un logo mémorable et distinctif.', photo: '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png', rating: 4, date: '2024-09-19' },
{ title: 'Les secrets dun logo réussi', description: 'Découvrez les astuces pour un logo mémorable et distinctif.', photo: '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png', rating: 4, date: '2024-09-19' },
{ title: 'Les secrets dun logo réussi', description: 'Découvrez les astuces pour un logo mémorable et distinctif.', photo: '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png', rating: 4, date: '2024-09-19' },
{ title: 'Les secrets dun logo réussi', description: 'Découvrez les astuces pour un logo mémorable et distinctif.', photo: '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png', rating: 4, date: '2024-09-19' },
{ title: 'Les secrets dun logo réussi', description: 'Découvrez les astuces pour un logo mémorable et distinctif.', photo: '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png', rating: 4, date: '2024-09-19' },
{ title: 'Les secrets dun logo réussi', description: 'Découvrez les astuces pour un logo mémorable et distinctif.', photo: '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png', rating: 4, date: '2024-09-19' },
{ title: 'Les secrets dun logo réussi', description: 'Découvrez les astuces pour un logo mémorable et distinctif.', photo: '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png', rating: 4, date: '2024-09-19' },
// autres objets...
]);
const scrollPosition = ref(0);
const cardHeight = 320;
const props = defineProps({
creator: {
type: Object,
required: true,
},
});
function handleScroll(event) {
event.preventDefault();
const scrollSpeed = 100;
scrollPosition.value += event.deltaY > 0 ? scrollSpeed : -scrollSpeed;
const totalRows = Math.ceil(contenuexclusif.value.length / getCurrentCols());
const visibleRows = 1000 / cardHeight;
const maxScrollPosition = totalRows * cardHeight - visibleRows * cardHeight + 360;
if (scrollPosition.value < 0) {
scrollPosition.value = 0;
} else if (scrollPosition.value > maxScrollPosition) {
scrollPosition.value = maxScrollPosition;
}
}
const gridColsClass = computed(() => {
const width = window.innerWidth;
if (width >= 1200) {
return 'grid-cols-4';
} else if (width >= 900) {
return 'grid-cols-3';
} else if (width >= 600) {
return 'grid-cols-2';
} else {
return 'grid-cols-1';
}
});
function getCurrentCols() {
const width = window.innerWidth;
if (width >= 1200) {
return 4;
} else if (width >= 900) {
return 3;
} else if (width >= 600) {
return 2;
} else {
return 1;
}
}
function editCard(item) {
console.log(`Editing card: ${item.title}`);
}
window.addEventListener('resize', () => {
gridColsClass.value = getCurrentCols();
});
</script>
<style>
.hover-card {
transition: transform 0.3s ease-in-out;
}
.hover-card:hover {
transform: scale(1.03); /* Effet de hover restauré */
}
.stars .v-icon {
font-size: 18px; /* Ajustez la taille des icônes */
}
.limited-text {
height: 60px; /* Limite la hauteur du texte */
overflow: hidden; /* Empêche le texte de dépasser */
text-overflow: ellipsis; /* Ajoute des points de suspension si le texte dépasse */
white-space: nowrap; /* Le texte reste sur une seule ligne */
}
.stars {
position: absolute; /* Fixe les étoiles au bas à droite */
bottom: 10px;
right: 10px;
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<v-dialog v-model="isVisible" fullscreen hide-overlay transition="fade-transition">
<v-card class="pa-0" :style="{ backgroundColor: brandingStore.colors.background, color: brandingStore.colors.onBackground }">
<v-btn
class="close-button"
icon
:style="{ backgroundColor: brandingStore.colors.secondary, color: brandingStore.colors.onSecondary }"
@click="close"
>
<v-icon>mdi-close</v-icon>
</v-btn>
<v-img :src="imageUrl" class="fullscreen-image"></v-img>
</v-card>
</v-dialog>
</template>
<script setup>
import {ref} from "vue";
import {useBrandingStore} from "@/stores/brandingStore.js";
const brandingStore = useBrandingStore();
const props = defineProps({
imageUrl: {
type: String,
required: true,
},
});
const isVisible = ref(false);
function open() {
isVisible.value = true;
}
function close() {
isVisible.value = false;
}
defineExpose({
open,
});
</script>
<style scoped>
.fullscreen-image {
height: 100vh;
width: 100%;
object-fit: contain;
}
.close-button {
position: absolute;
top: 50px;
right: 50px;
z-index: 10;
}
</style>

View File

@@ -0,0 +1,97 @@
<script setup>
import {useSubscriptionStore} from "@/stores/subscriptionStore.js";
import {computed, ref} from "vue";
import {useBrandingStore} from "@/stores/brandingStore.js";
import {useRouter} from "vue-router";
const router = useRouter()
const brandingStore = useBrandingStore()
const subscriptionStore = useSubscriptionStore()
const isSubscribe = computed(() => !subscriptionStore.isSubscribeTo(brandingStore.value.id));
function subscribeToCreator() {
const target = `@${brandingStore.currentBrand}/subscription`;
router.push(target)
}
// Référence pour contrôler l'affichage du modal
const showUnsubscribeModal = ref(false);
function unsubscribeFromCreator() {
subscriptionStore.unsubscribeFrom(brandingStore.value.id);
// Fermer le modal après désabonnement
showUnsubscribeModal.value = false;
}
</script>
<template>
<template v-if="isSubscribe">
<v-btn
:style="{
width: '150px',
height: '28px',
backgroundColor: brandingStore.colors.secondary,
color: 'white',
borderRadius: '8px',
padding: '10px 24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background-color 0.3s ease'
}"
@click="subscribeToCreator"
>
{{ $t('subscribebutton.subscribe') }}
</v-btn>
</template>
<template v-else>
<v-btn
:style="{
width: '150px',
height: '28px',
backgroundColor: brandingStore.colors.secondary,
color: 'white',
borderRadius: '0 8px 8px 0',
padding: '10px 24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background-color 0.3s ease'
}"
@click="showUnsubscribeModal = true"
>
<div>{{ $t('subscribebutton.unsubscribe') }}</div>
</v-btn>
</template>
<v-dialog v-model="showUnsubscribeModal" max-width="500">
<v-card class="text-center rounded-xl"
:style="{ border: `3px solid ${brandingStore.colors.secondary}` }">
<div class="flex items-center justify-between py-4 text-2xl font-bold border-b mb-2">
<div class="flex-1 text-center">
Déabonnement
</div>
</div>
<v-card-title>Confirmation</v-card-title>
<v-card-text>Êtes-vous sûr de vouloir vous désabonner ?</v-card-text>
<v-card-actions class="justify-center px-4 pb-4">
<v-btn text class="flex-grow-1" variant="outlined"
:style="{ backgroundColor: 'rgba(255, 255, 255, 0.1)', color: 'rgba(0, 0, 0, 0.4)' }"
@click="unsubscribeFromCreator">Oui
</v-btn>
<v-btn class="flex-grow-1"
:style="{ borderColor: brandingStore.colors.secondary, color: brandingStore.colors.secondary }"
variant="outlined"
@click="showUnsubscribeModal = false">
<div :style="{ color: brandingStore.colors.secondary }">Non</div>
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
import {useSubscriptionStore} from "@/stores/subscriptionStore.js";
const subscriptionStore = useSubscriptionStore()
</script>
<template>
<template v-if="Object.keys(subscriptionStore.subscriptions).length > 0">
<template v-for="subscription in subscriptionStore.subscriptions">
<RouterLink class="capitalize" :to="`/@${subscription.creatorName}`">
<div class="flex items-center content-center font-sans font-semibold pt-2 ">
<img
:src="subscription.creatorPortraitUrl"
alt="Profile Image"
class="rounded-full mx-2"
width="32px"
height="32px">
{{ subscription.creatorName }}
</div>
</RouterLink>
</template>
</template>
<template v-else>
<span>No subscriptions</span>
</template>
</template>

View File

@@ -0,0 +1,99 @@
<script setup>
import {useBrandingStore} from "@/stores/brandingStore.js";
import {ref, onMounted} from 'vue';
import {useClient} from "@/plugins/api.js";
import {useRoute, useRouter} from "vue-router";
const router = useRouter()
const brandingStore = useBrandingStore();
const tiers = ref([]);
// Fetch tiers from API
async function fetchTiers() {
const client = useClient()
const response = await client.get(
`/api/membership/tiers/${brandingStore.value.id}`
);
tiers.value = response.data;
}
// Fetch tiers when the component is mounted
onMounted(() => {
fetchTiers();
});
// Colors
const onPrimary = {color: brandingStore.colors.onPrimary}
const Primary = {backgroundColor: brandingStore.colors.primary}
const onSecondaryColor = {color: brandingStore.colors.onSecondary}
const secondaryColor = {backgroundColor: brandingStore.colors.secondary}
const route = useRoute()
const baseUrl = window.location.origin;
const creatorSlug = route.params.creator_slug || route.path.split('/')[1];
const successUrl = `${baseUrl}/${creatorSlug}/content`
const cancelledUrl = `${baseUrl}/${creatorSlug}`
async function doSubscribe(tier) {
try {
const client = useClient()
const response = await client.post(
`/api/membership/subscribe`,
{
creatorId: brandingStore.value.id,
tierId: tier.id,
checkoutSuccessUrl: successUrl, // TODO: ensure the success-url will insert subscription
checkoutCancelledUrl: cancelledUrl
})
window.location.href = response.data.stripeCheckoutUrl;
} catch (error) {
console.error("Error loading subscriptions:", error);
}
}
</script>
<template>
<v-container class="d-flex justify-center">
<v-row justify="center">
<v-col
:cols="12 / Math.min(tiers.length, 3)"
md="4"
v-for="tier in tiers"
:key="tier.id"
>
<v-btn @click="doSubscribe(tier)" variant="text">
<div class="bg-white shadow-2xl rounded-2xl">
<v-img src="/images/hutopymedia/loginpage/loginhutopy.png" class="rounded-t-2xl"></v-img>
<div class="pa-6" :style="[Primary, onPrimary]">
<v-card-title class="text-h4 text-center py-4 ">{{ tier.name }}</v-card-title>
<div class="text-justify">{{ tier.description }}</div>
</div>
<v-card-text class="text-center rounded-b-2xl" :style="[secondaryColor, onSecondaryColor]">
<span class="text-h5">{{ tier.price }} $ / par mois</span>
</v-card-text>
</div>
</v-btn>
</v-col>
</v-row>
</v-container>
</template>
<style scoped>
.v-card {
border-radius: 12px;
}
.dotted-border {
border: 2px dotted;
padding: 1px;
}
</style>

View File

@@ -0,0 +1,134 @@
<template>
<div>
<h1 class="h1-tos py-10 flex items-center justify-center uppercase">À propos</h1>
</div>
<div class="mx-auto max-w-[1000px] px-4">
<p class="text-justify p-tos">
Bienvenue sur la page "À Propos" dHutopy, nous partageons notre histoire, notre mission,
notre vision, et vous présentons l'équipe passionnée qui rend tout cela possible. Hutopy
n'est pas seulement une plateforme ; c'est une communauté, un mouvement, un lieu où la
créativité rencontre la technologie pour créer des expériences inoubliables.
</p>
<h2 class="h2-tos">
Notre Histoire
</h2>
<p class="text-justify p-tos">
Hutopy a été fondée en 2024, née de l'idée simple mais puissante que chaque créateur qu'il
soit grand ou petit, novice ou expérimenté, devrait avoir accès aux outils et au soutien
nécessaires pour partager sa passion avec le monde.
</p>
<h2 class="h2-tos">
Notre Mission
</h2>
<p class="text-justify p-tos">
Notre mission est de démocratiser la création de contenu numérique, en offrant une
plateforme accessible, intuitive et puissante qui permet aux créateurs de tout horizon de
s'exprimer, d'innover et de connecter avec une audience mondiale. Nous nous engageons à
fournir les outils, les ressources et le soutien nécessaires pour que chaque voix puisse
être entendue.
</p>
<h2 class="h2-tos">
Notre Vision
</h2>
<p class="text-justify p-tos">
Nous envisageons un monde où la barrière entre les créateurs et leur audience est réduite au
minimum, où les idées, l'expertise et les histoires peuvent circuler librement et sans
entrave. Hutopy aspire à être au cœur de cet écosystème créatif et professionnel, en étant
une source d'inspiration, une plateforme de lancement et un foyer pour tous.
</p>
<h2 class="h2-tos">
Notre Équipe
</h2>
<p class="text-justify p-tos">
Derrière Hutopy, il y a une équipe de penseurs innovants, de créatifs passionnés et de
technologues dévoués, tous unis par le désir de soutenir la communauté des créateurs de
contenu. Notre équipe est notre plus grande force, chaque membre apportant une expertise
unique et une perspective fraîche à notre mission commune.
</p>
<br>
<v-row justify="center">
<v-card max-width="250px" class="card-member" style="margin: 10px;">
<img class="member-profile-picture"
src="/images/hutopymedia/tospage/membersPictures/profileMarco.png"
alt="Marc-Olivier Hébert">
<div class="card-content">
<div class="member-name">Marc-Olivier</div>
<div class="member-name">Hébert</div>
<div class="member-title">Fondateur</div>
<p class="member-description">Avec une vision claire et un engagement sans faille, il a lancé Hutopy pour changer la manière dont le contenu est créé et partagé.</p>
</div>
</v-card>
<v-card max-width="250px" class="card-member" style="margin: 10px;">
<img class="member-profile-picture"
src="/images/hutopymedia/tospage/membersPictures/profileDominique.png"
alt="Dominic Villemure">
<div class="card-content">
<div class="member-name">Dominic</div>
<div class="member-name">Villemure</div>
<div class="member-title">Responsable Technique</div>
<p class="member-description">À la tête de notre équipe de développement, il assure quHutopy reste à la pointe de la technologie.</p>
</div>
</v-card>
<v-card max-width="250px" class="card-member" style="margin: 10px;">
<img class="member-profile-picture"
src="/images/hutopymedia/tospage/membersPictures/profilePascal.png"
alt="Pascal Marchesseault">
<div class="card-content">
<div class="member-name">Pascal</div>
<div class="member-name">Marchesseault</div>
<div class="member-title">Gestionnaire de projet / UI</div>
<p class="member-description">A pour mission d'assurer le développement du projet tout en créant une interface qui permettra au projet d'avoir une interaction positive et enrichissante avec Hutopy pour les utilisateurs.</p>
</div>
</v-card>
<v-card max-width="250px" class="card-member" style="margin: 10px;">
<img class="member-profile-picture"
src="/images/hutopymedia/tospage/membersPictures/profileChloe.png"
alt="Chloé Beaugrand">
<div class="card-content">
<div class="member-name">Chloé</div>
<div class="member-name">Beaugrand</div>
<div class="member-title">Responsable Marketing</div>
<p class="member-description">Elle façonne l'image dHutopy et engage notre communauté à travers des campagnes innovantes et impactantes.</p>
</div>
</v-card>
<v-card max-width="250px" class="card-member" style="margin: 10px;">
<img class="member-profile-picture"
src="/images/hutopymedia/tospage/membersPictures/Jonathan.png"
alt="Édouard Letarte">
<div class="card-content">
<div class="member-name">Jonathan</div>
<div class="member-name">Bourdon</div>
<div class="member-title">Programeur / Architecte</div>
<p class="member-description">Son expérience d'architecte senior nous permet de développer un logiciel avec une durabilité qui nous permettra de nous développer pendant de longues années.</p>
</div>
</v-card>
</v-row>
<p class="text-justify py-6 p-tos">
Chez Hutopy, nous sommes plus qu'une plateforme ; nous sommes une famille dédiée à la
réussite de nos créateurs. Nous vous invitons à nous joindre dans cette aventure
passionnante, à partager votre créativité et votre expertise avec le monde et à faire
dHutopy votre utopie. Merci de faire partie de notre histoire.
</p>
</div>
</template>
<style>
@import '@/cssstyle/tosstyle.css';
</style>

View File

@@ -0,0 +1,131 @@
<template>
<div>
<h1 class="h1-tos py-10 flex items-center justify-center uppercase">Politique de Contenu</h1>
</div>
<div class="mx-auto max-w-[1000px] px-4">
<h2 class="h2-tos">Introduction</h2>
<p class="text-justify p-tos">
Hutopy vise à offrir une plateforme sécurisée, inclusive et respectueuse les créateurs peuvent partager leur travail et interagir avec une communauté engagée. Pour maintenir cet environnement, nous avons établi des lignes directrices claires concernant le type de contenu autorisé sur notre plateforme. En utilisant Hutopy, vous acceptez de respecter cette politique de contenu.
</p>
<h2 class="h2-tos">Contenu Autorisé</h2>
<ul class="list-disc pl-5">
<li class="text-justify p-tos">
Hutopy encourage la publication de contenu créatif, éducatif et inspirant dans divers formats, y compris :
</li>
<li class="text-justify p-tos">Arts visuels et design : Illustrations, photographies, designs graphiques respectant le droit d'auteur.</li>
<li class="text-justify p-tos">Éducation et apprentissage : Tutoriels, cours en ligne, webinaires qui favorisent l'apprentissage et le développement personnel.</li>
<li class="text-justify p-tos">Contenu écrit : Articles, blogs, poésies qui enrichissent les discussions et partagent des connaissances.</li>
<li class="text-justify p-tos">Multimédia : Vidéos, podcasts et musique originales qui respectent les droits d'auteur et encouragent l'expression créative.</li>
</ul>
<h2 class="h2-tos">Contenu Interdit</h2>
<ul class="list-disc pl-5">
<li class="text-justify p-tos">
Pour protéger notre communauté, certains types de contenu ne sont pas autorisés sur Hutopy, incluant mais non limité à :
</li>
<li class="text-justify p-tos">Contenu illégal : Tout contenu promouvant des activités illégales ou fournissant des instructions pour commettre des actes illégaux.</li>
<li class="text-justify p-tos">Harcèlement et discours de haine : Contenu visant à harceler, menacer, ou promouvoir la haine contre des individus ou des groupes basés sur la race, l'ethnie, la religion, le genre, l'orientation sexuelle, l'identité de genre ou tout autre caractère distinctif.</li>
<li class="text-justify p-tos">Contenu pour adultes : Matériel pornographique ou explicitement sexuel.</li>
<li class="text-justify p-tos">Violence et contenu graphique : Images ou descriptions de violence excessive, gore ou choquantes.</li>
<li class="text-justify p-tos">Publicité mensongère et spam : Contenu trompeur, frauduleux ou spammy.</li>
</ul>
<h2 class="h2-tos">Droits d'Auteur et Propriété Intellectuelle</h2>
<p class="text-justify p-tos">
Respect des Droits : Vous devez posséder les droits sur le contenu que vous publiez sur Hutopy ou avoir l'autorisation expresse du détenteur des droits pour utiliser ce contenu.
</p>
<p class="text-justify p-tos">
Attribution : Lorsque vous utilisez ou adaptez le contenu protégé par des droits d'auteur appartenant à autrui, une attribution claire et correcte doit être fournie.
</p>
<h2 class="h2-tos">Modération et Signalement</h2>
<p class="text-justify p-tos">
Modération : Hutopy utilise à la fois des modérateurs humains et des outils automatisés pour surveiller et évaluer le contenu publié, garantissant le respect de cette politique.
</p>
<p class="text-justify p-tos">
Signalement : Les utilisateurs de Hutopy sont encouragés à signaler tout contenu qu'ils considèrent comme enfreignant notre politique de contenu via les outils de signalement disponibles sur la plateforme.
</p>
<h2 class="h2-tos">Conséquences des Violations</h2>
<p class="text-justify p-tos">
La violation de notre politique de contenu peut entraîner des actions allant de l'avertissement à la suppression du contenu ou à la suspension, voire à la résiliation du compte utilisateur.
</p>
<h2 class="h2-tos">Révisions de la Politique</h2>
<p class="text-justify p-tos">
Hutopy se réserve le droit de modifier cette politique de contenu à tout moment pour refléter les changements dans nos pratiques ou pour se conformer à de nouvelles réglementations légales.
</p>
<h2 class="h2-tos">Dans le cas dune non conformité aux politiques de contenus :</h2>
<p class="text-justify p-tos">
1. Suspension des Fonds : Les montants accumulés sur le compte de l'utilisateur en question seront suspendus temporairement le temps de l'évaluation.
</p>
<p class="text-justify p-tos">
2. Redistribution à des Œuvres de Charité : Si, après évaluation, le contenu est définitivement jugé non conforme à nos clauses de conformité, les fonds suspendus seront redistribués à des œuvres de charité choisies par Hutopy. L'utilisateur concerné sera informé de cette décision et des raisons de la non-conformité de son contenu.
</p>
<p class="text-justify p-tos">
Cette mesure vise à renforcer la responsabilité des créateurs quant au type de contenu partagé sur Hutopy, tout en soutenant des causes bénéfiques en cas de violation de nos directives.
</p>
<h2 class="h2-tos">Section Anti-Exploitation dHutopy</h2>
<h2 class="h2-tos" style="margin-top: 25px; margin-bottom: 25px; font-weight: 600; font-size: 1.3rem;">Engagement dHutopy</h2>
<p class="text-justify p-tos">
Hutopy s'engage fermement à maintenir une plateforme sûre et respectueuse pour tous ses utilisateurs. Nous prenons une position intransigeante contre toute forme d'exploitation humaine et nous travaillons activement pour prévenir, identifier et combattre les comportements et contenus exploitants. Notre mission est de créer un environnement où la créativité et l'expression personnelle peuvent s'épanouir sans crainte d'exploitation ou d'abus.
</p>
<h2 class="h2-tos">Politique de Tolérance Zéro</h2>
<p class="text-justify p-tos">
Nous appliquons une politique de tolérance zéro à l'égard de :
</p>
<ul class="list-disc pl-5">
<li class="text-justify p-tos">Exploitation sexuelle : Cela inclut, mais n'est pas limité à, la pornographie infantile, le trafic sexuel, et le harcèlement sexuel.</li>
<li class="text-justify p-tos">Travail forcé : Nous nous opposons à toute forme de travail forcé ou de servitude, y compris le travail des enfants.</li>
<li class="text-justify p-tos">Exploitation financière : Cela comprend les arnaques, la fraude et tout autre type d'exploitation financière.</li>
</ul>
<h2 class="h2-tos">Politique de Tolérance Zéro et Signalement/Actions</h2>
<ul class="list-disc pl-5">
<li class="text-justify p-tos">Mécanismes de Signalement : Hutopy fournit des outils faciles à utiliser pour signaler rapidement tout contenu ou comportement suspect d'exploitation. Nous encourageons vivement les utilisateurs à utiliser ces outils s'ils rencontrent ou soupçonnent des cas d'exploitation.</li>
<li class="text-justify p-tos">Réponse Rapide : Notre équipe dédiée examine tous les signalements avec la plus grande attention et prend des mesures immédiates pour adresser les problèmes signalés. Cela peut inclure la suppression de contenu, la suspension de comptes, et, si nécessaire, le signalement aux autorités compétentes.</li>
</ul>
<h2 class="h2-tos">Collaboration avec les Autorités</h2>
<p class="text-justify p-tos">
Nous collaborons étroitement avec les autorités et les organisations spécialisées pour combattre l'exploitation sous toutes ses formes. Hutopy est déterminé à respecter toutes les lois applicables et à coopérer avec les autorités dans leurs efforts de lutte contre l'exploitation et l'abus.
</p>
<h2 class="h2-tos">Engagements des Utilisateurs</h2>
<p class="text-justify p-tos">
En rejoignant Hutopy, les utilisateurs s'engagent à respecter nos principes anti-exploitation et à contribuer à la création d'un espace sûr pour tous. Tout manquement à ces engagements entraînera des conséquences sérieuses, conformément à notre politique de tolérance zéro.
</p>
<h2 class="h2-tos">Modération et Signalement</h2>
<ul class="list-disc pl-5">
<li class="text-justify p-tos">Modération : Hutopy utilise à la fois des modérateurs humains et des outils automatisés pour surveiller et évaluer le contenu publié, garantissant le respect de cette politique.</li>
<li class="text-justify p-tos">Signalement : Les utilisateurs dHutopy sont encouragés à signaler tout contenu qu'ils considèrent comme enfreignant notre politique de contenu via les outils de signalement disponibles sur la plateforme.</li>
</ul>
<h2 class="h2-tos">Conséquences des Violations</h2>
<p class="text-justify p-tos">
La violation de notre politique de contenu peut entraîner des actions allant de l'avertissement à la suppression du contenu ou à la suspension, voire à la résiliation du compte utilisateur.
</p>
<h2 class="h2-tos">Révisions de la Politique</h2>
<p class="text-justify p-tos">
Hutopy se réserve le droit de modifier cette politique de contenu à tout moment pour refléter les changements dans nos pratiques ou pour se conformer à de nouvelles réglementations légales.
</p>
<h2 class="h2-tos">Contact</h2>
<p class="text-justify p-tos">
Si vous avez des questions sur cette politique de contenu ou sur la manière dont nous l'appliquons, veuillez contacter notre équipe d'assistance à <a href="mailto:support@hutopy.com">support@hutopy.com</a>.
</p>
</div>
</template>
<style>
@import '@/cssstyle/tosstyle.css';
</style>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,46 @@
<template>
<div>
<h1 class="h1-tos py-10 flex items-center justify-center uppercase">Guide pour les Créateurs</h1>
</div>
<div class="mx-auto max-w-[1000px] px-4">
<h2 class="h2-tos">Bienvenue dans la Communauté de Créateurs dHutopy</h2>
<p class="text-justify p-tos">
Félicitations pour avoir choisi Hutopy pour partager votre créativité et votre savoir ! Ce guide est conçu pour vous aider à maximiser votre présence sur la plateforme, à engager votre audience et à tirer le meilleur parti des outils à votre disposition.
</p>
<h2 class="h2-tos">1. Création de Votre Profil de Créateur :</h2>
<ul class="list-disc pl-5">
<li class="text-justify p-tos">Personnalisez Votre Profil : Ajoutez une photo de profil, une bannière et une bio qui reflète votre personnalité et votre marque de créateur.</li>
<li class="text-justify p-tos">Liens et Contacts : Intégrez des liens vers vos autres plateformes sociales.</li>
</ul>
<h2 class="h2-tos">2. Publication de Contenu :</h2>
<ul class="list-disc pl-5">
<li class="text-justify p-tos">Diversifiez Votre Contenu : Explorez différents formats vidéos, articles, podcasts pour captiver divers segments d'audience.</li>
<li class="text-justify p-tos">Planification et Consistance : Publiez régulièrement pour garder votre audience engagée. Utilisez l'outil de planification dHutopy pour organiser vos publications à l'avance.</li>
</ul>
<h2 class="h2-tos">3. Engagement avec Votre Audience :</h2>
<ul class="list-disc pl-5">
<li class="text-justify p-tos">Interagissez : Répondez aux commentaires, participez à des discussions et créez des sondages pour encourager l'interaction.</li>
<li class="text-justify p-tos">Analysez Vos Performances : Utilisez les outils d'analyse dHutopy pour comprendre ce qui résonne avec votre audience et ajustez votre stratégie en conséquence.</li>
</ul>
<h2 class="h2-tos">4. Monétisation :</h2>
<ul class="list-disc pl-5">
<li class="text-justify p-tos">Explorez les Options : Hutopy offre plusieurs voies de monétisation, y compris les abonnements payants, les dons et le programme d'ambassadeur. Choisissez ce qui convient le mieux à votre contenu et à votre audience.</li>
</ul>
<h2 class="h2-tos">5. Croissance et Développement :</h2>
<ul class="list-disc pl-5">
<li class="text-justify p-tos">Continuez à Apprendre : Utilisez le Centre de Ressources Éducatives dHutopy pour améliorer vos compétences et rester à jour sur les tendances du secteur. (À venir)</li>
</ul>
</div>
</template>
<style>
@import '@/cssstyle/tosstyle.css';
</style>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,8 @@
<script setup>
</script>
<template>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,23 @@
<script setup>
import Footer from "@/views/main/Footer.vue";
</script>
<template>
<div class="flex justify-center">
<img src="/images/hutopymedia/banners/hutopyul.png"
class="max-w-[1000px] rounded-2xl shadow" alt="">
</div>
<div class="py-8 flex-grow">
<router-view></router-view>
</div>
<Footer></Footer>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,113 @@
<template>
<div>
<h1 class="h1-tos py-10 flex items-center justify-center uppercase">FAQ</h1>
</div>
<div class="mx-auto max-w-[1000px] px-4">
<h2 class="h2-tos">Foire Aux Questions</h2>
<p class="text-justify p-tos">
La section FAQ de Hutopy est votre ressource essentielle pour trouver des réponses rapides aux questions les plus
fréquemment posées sur notre plateforme. Explorez nos réponses détaillées pour optimiser votre utilisation de
Hutopy et résoudre vos problèmes en un instant. Consultez régulièrement notre FAQ pour rester informé des
dernières fonctionnalités.
</p>
<h2 class="h2-tos">1. Comment puis-je créer un compte sur Hutopy ?</h2>
<p class="text-justify p-tos">
Créer un compte est simple ! Visitez notre page d'inscription, remplissez les informations requises, et suivez les
instructions pour confirmer votre adresse e-mail ou vous connecter via les partenaires de connexion. Vous pourrez
commencer à explorer et à interagir avec la communauté Hutopy immédiatement après.
</p>
<h2 class="h2-tos">2. Quels types de contenu puis-je publier sur Hutopy ?</h2>
<p class="text-justify p-tos">
Hutopy accueille une large variété de contenus créatifs, incluant mais non limité à des vidéos, articles,
podcasts, et illustrations. Nous encourageons la diversité et l'originalité, tant que le contenu respecte nos
valeurs.
</p>
<h2 class="h2-tos">3. Comment Hutopy rémunère-t-il les créateurs de contenu ?</h2>
<p class="text-justify p-tos">
Les créateurs peuvent monétiser leur contenu de plusieurs façons, notamment via des abonnements payants et des
dons de la part des utilisateurs.
</p>
<h2 class="h2-tos">4. Comment puis-je modifier mon profil ?</h2>
<p class="text-justify p-tos">
Connectez-vous à votre compte, accédez à votre profil, puis cliquez sur "Éditer le profil" pour modifier vos
informations, ajouter une bio, changer votre photo de profil, et plus encore.
</p>
<h2 class="h2-tos">5. Est-il possible de supprimer mon compte ?</h2>
<p class="text-justify p-tos">
Oui, vous pouvez faire la suppression de votre compte sur votre profil dans la section plus. Notez que cette
action est irréversible.
</p>
<h2 class="h2-tos">6. Que faire si j'oublie mon mot de passe ?</h2>
<p class="text-justify p-tos">
Sur la page de connexion, cliquez sur "Mot de passe oublié ?" et suivez les instructions pour réinitialiser votre
mot de passe via votre adresse courriel.
</p>
<h2 class="h2-tos">7. Comment signaler un contenu inapproprié ?</h2>
<p class="text-justify p-tos">
Si vous rencontrez du contenu qui viole nos directives, cliquer sur les trois petits points en haut de la
publication et cliquez sur le bouton "Signaler" associé au contenu en question pour alerter notre équipe de
modération.
</p>
<h2 class="h2-tos">8. Comment puis-je contacter le support Hutopy ?</h2>
<p class="text-justify p-tos">
Pour toute assistance, vous pouvez nous contacter via notre formulaire en ligne ou par e-mail à
support@hutopy.com, ou via nos réseaux sociaux. Notre équipe s'efforce de répondre rapidement à toutes les
demandes.
</p>
<h2 class="h2-tos">9. Quels sont les frais pour les créateurs sur Hutopy ?</h2>
<p class="text-justify p-tos">
Hutopy prélève une commission de 12% + 0,30$ sur chaque transaction réalisée sur la plateforme, que ce soit pour
les abonnements, les dons ou tout autre revenu généré par les créateurs. Cette commission nous aide à couvrir les
coûts de maintenance de la plateforme, de la bande passante, d'assistance utilisateur, des frais de transaction de
Stripe et le développement continu pour améliorer votre expérience sur Hutopy.
</p>
<h2 class="h2-tos">10. Y a-t-il des frais pour s'inscrire ou pour maintenir mon compte sur Hutopy ?</h2>
<p class="text-justify p-tos">
Non, l'inscription sur Hutopy est gratuite, et il n'y a pas de frais mensuels ou annuels pour maintenir votre
compte. Vous pouvez commencer à utiliser Hutopy et à partager votre contenu sans aucun coût initial.
</p>
<h2 class="h2-tos">11. Les utilisateurs doivent-ils payer pour accéder au contenu sur Hutopy ?</h2>
<p class="text-justify p-tos">
Hutopy offre à la fois du contenu gratuit et du contenu premium. Les utilisateurs peuvent accéder gratuitement à
une partie du contenu sur la plateforme. Cependant, certains créateurs peuvent choisir de rendre leur contenu
accessible uniquement via un abonnement payant ou un achat unique pour soutenir leur travail.
</p>
<h2 class="h2-tos">12. Existe-t-il des frais pour retirer mes gains de la plateforme ?</h2>
<p class="text-justify p-tos">
Les créateurs peuvent retirer leurs gains sans frais supplémentaires de la part dHutopy. Cependant, les
transactions bancaires ou les transferts vers des portefeuilles électroniques peuvent être soumis aux frais
standards imposés par ces services ou institutions financières, mais pas par Hutopy.
</p>
<h2 class="h2-tos">13. Les frais Hutopy sont-ils les mêmes pour tous les types de contenu ?</h2>
<p class="text-justify p-tos">
Oui, les frais de commission dHutopy sont uniformément appliqués à tous les types de contenu et de transactions
sur la plateforme pour maintenir la simplicité et la transparence et ce peu importe le montant.
</p>
</div>
</template>
<style>
@import '@/cssstyle/tosstyle.css';
.important {
@apply m-2 text-red-500 my-4;
}
</style>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,42 @@
<template>
<div>
<h1 class="h1-tos py-10 flex items-center justify-center uppercase">Aide et contact</h1>
</div>
<div class="mx-auto max-w-[1000px] px-4">
<p class="text-justify p-tos">
Bienvenue dans notre espace d'assistance ! Que vous soyez un créateur à la recherche de conseils pour optimiser votre présence sur Hutopy, ou un utilisateur curieux d'en apprendre plus sur notre plateforme, vous êtes au bon endroit. Notre objectif est de vous fournir tout le soutien nécessaire pour que votre expérience sur Hutopy soit aussi enrichissante et agréable que possible.
</p>
<h2 class="h2-tos">FAQ (Foire Aux Questions)</h2>
<p class="text-justify">
Retrouvez les réponses aux questions les plus fréquemment posées concernant l'utilisation dHutopy, les fonctionnalités de la plateforme, les options de monétisation, et plus encore. Consulter la FAQ
</p>
<h2 class="h2-tos">Contactez-Nous</h2>
<p class="text-justify">
Nous sommes toujours ravis d'entendre nos utilisateurs ! Que ce soit pour partager vos retours, poser une question spécifique, ou demander des renseignements sur des partenariats, n'hésitez pas à nous contacter.
</p>
<p class="text-justify">
- Par E-mail : <a href="mailto:info@hutopy.com" style="color: #a30e79;">info@hutopy.com</a><br>
- Réseaux Sociaux : Nous sommes actifs sur <a href="https://www.facebook.com/Hutopy" style="color: #a30e79;">Facebook</a>, et <a href="https://www.instagram.com/hutopy.inc" style="color: #a30e79;">Instagram</a><br>
- Suivez-nous pour rester informé et interagir avec notre communauté.
</p>
<h2 class="h2-tos">Assistance Technique</h2>
<p class="text-justify">
Rencontrez-vous un problème technique ?
Notre équipe d'assistance est pour vous aider : <a href="mailto:support@hutopy.com" style="color: #a30e79;">support@hutopy.com</a>
</p>
<p class="text-justify my-5">
Nous sommes pour rendre votre expérience sur Hutopy aussi fluide et positive que possible. N'hésitez pas à nous contacter pour toute aide supplémentaire !
</p>
</div>
</template>
<style>
@import '@/cssstyle/tosstyle.css';
</style>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,27 @@
<template>
<div>
<h1 class="h1-tos py-10 flex items-center justify-center uppercase">Frais</h1>
</div>
<div class="mx-auto max-w-[1000px] px-4">
<p class="text-justify p-tos">
Découvrez Hutopy, l'endroit où la valorisation de votre travail atteint son apogée. Avec une commission réduite à seulement 9 %, notre engagement envers votre succès est palpable. Chaque pourcentage prélevé est réinvesti avec soin pour catalyser votre croissance afin de développer des fonctionnalités innovantes, maintenir une infrastructure technologique de pointe, et un support utilisateur de premier ordre. Notre objectif ? Amplifier votre expansion et garantir une expérience utilisateur sans précédent.
</p>
<p class="text-justify p-tos">
Pour chaque transaction, un frais minime assure la sécurité et la fiabilité des paiements, grâce à un partenaire de confiance mondialement reconnu. Ce dernier sécurise des milliards en transactions chaque année pour une diversité d'entreprises, à des entreprises en démarrage aux conglomérats établis. Ce gage de sécurité est disponible pour une somme de 2,9 % plus un 0,30 $ par transaction, une petite contribution pour la tranquillité d'esprit et la protection de vos revenus.
</p>
<p class="text-justify p-tos">
Notre modèle tarifaire, pensé pour la simplicité et la transparence, a pour ambition ultime d'optimiser vos gains. Chez Hutopy, la notion de partenariat prend tout son sens : votre épanouissement est au cœur de nos préoccupations. Bénéficiez d'une plateforme qui élargit votre horizon créatif et entrepreneurial, tout en vous assurant que vos intérêts et ceux de vos donnateurs sont précieusement gardés.
</p>
<p class="text-justify p-tos">
Hutopy est plus qu'une plateforme ; c'est une communauté où la transformation de la passion en profit devient réalité, grâce au soutien indéfectible d'une équipe dévouée à enrichir votre parcours. Nous vous invitons à nous rejoindre pour explorer ensemble les avenues de succès, tout en vous garantissant une part conséquente de vos revenus. Embarquez dans une aventure votre présence en ligne ne connaît pas de limites, soutenue par Hutopy, votre allié dans la quête du succès.
</p>
</div>
</template>
<style>
@import '@/cssstyle/tosstyle.css';
</style>

View File

@@ -0,0 +1,71 @@
<template>
<div>
<h1 class="h1-tos py-10 flex items-center justify-center uppercase">Conditions générales</h1>
</div>
<div class="mx-auto max-w-[1000px] px-4">
<h2 class="h2-tos">Bienvenue sur Hutopy</h2>
<p class="text-justify p-tos">
En accédant à la plateforme Hutopy et en l'utilisant, vous acceptez de vous conformer aux conditions générales d'utilisation suivantes, qui sont conçues pour assurer une expérience sûre, respectueuse et positive pour tous les utilisateurs. Ces conditions s'appliquent à tous les visiteurs, utilisateurs et autres personnes qui accèdent ou utilisent le service.
</p>
<h2 class="h2-tos">Utilisation Acceptable</h2>
<p class="text-justify p-tos">
1. Contenu : Vous vous engagez à ne pas publier de contenu illégal, diffamatoire, abusif, pornographique, haineux, raciste ou de toute autre nature susceptible de causer du tort. Tout contenu publié reste sous votre responsabilité.
</p>
<p class="text-justify p-tos">
2. Comportement : Tout comportement visant à nuire à d'autres utilisateurs, à la plateforme ou à ses opérations est strictement interdit. Cela inclut le piratage, la diffusion de logiciels malveillants et les tentatives d'hameçonnage.
</p>
<h2 class="h2-tos">Droits de Propriété Intellectuelle</h2>
<p class="text-justify p-tos">
Le contenu publié sur Hutopy par les utilisateurs reste la propriété de leurs créateurs respectifs. En publiant du contenu sur Hutopy, vous accordez à la plateforme une licence non exclusive, transférable, libre de droits et mondiale pour utiliser, reproduire, modifier, publier, traduire et distribuer ce contenu dans tout média.
</p>
<h2 class="h2-tos">Confidentialité</h2>
<p class="text-justify p-tos">
La protection de vos données personnelles est de la plus haute importance pour Hutopy. Votre information est collectée et utilisée conformément à notre politique de confidentialité.
</p>
<h2 class="h2-tos">Limitation de Responsabilité</h2>
<p class="text-justify p-tos">
Hutopy et ses affiliés ne seront pas responsables des dommages indirects, accidentels, spéciaux, consécutifs ou punitifs, y compris sans limitation, la perte de profits, de données ou d'usage, que ce soit dans une action contractuelle, délictuelle y compris la négligence ou autre, découlant de ou en relation avec l'accès ou l'utilisation de la plateforme Hutopy.
</p>
<h2 class="h2-tos">Clause de Non-Poursuite</h2>
<p class="text-justify p-tos">
En acceptant ces conditions générales d'utilisation, vous convenez qu'en aucun cas Hutopy, ses dirigeants, employés, partenaires, agents, fournisseurs ou affiliés ne pourront être tenus responsables de dommages directs, indirects, accidentels, spéciaux, consécutifs ou exemplaires résultant de votre utilisation de la plateforme Hutopy. Par conséquent, vous renoncez expressément à tout droit de poursuivre Hutopy et ses affiliés pour toute réclamation liée à votre utilisation de la plateforme.
</p>
<h2 class="h2-tos">Gestion du Contenu Inapproprié et Sanctions Financières</h2>
<p class="text-justify p-tos">
Hutopy s'engage à maintenir un environnement sûr et respectueux pour tous ses utilisateurs. Ainsi, tout contenu publié sur la plateforme est sujet à une évaluation de conformité avec nos directives et nos standards éthiques. Dans l'éventualité le contenu d'un utilisateur est jugé inapproprié, offensant ou en violation avec nos Acceptation des Conditions
<br><br>
Votre accès et votre utilisation continue de la plateforme Hutopy constituent votre acceptation des présentes conditions générales et de toutes les modifications futures. Il est de votre responsabilité de vous tenir informé des mises à jour de ces conditions.
<br><br>
Nous vous encourageons à utiliser Hutopy de manière responsable et conforme à nos directives, afin de contribuer à une communauté positive et enrichissante pour tous.
</p>
<h2 class="h2-tos">Modifications des Conditions</h2>
<p class="text-justify p-tos">
Hutopy se réserve le droit de modifier ou de remplacer ces conditions à tout moment. Il est de votre responsabilité de revoir régulièrement ces conditions pour vous tenir informé des mises à jour.
</p>
<h2 class="h2-tos">Résiliation</h2>
<p class="text-justify p-tos">
Hutopy peut résilier ou suspendre votre accès à la plateforme immédiatement, sans préavis ni responsabilité, pour quelque raison que ce soit, y compris, sans limitation, si vous violez les conditions.
</p>
<h2 class="h2-tos">Loi Applicable</h2>
<p class="text-justify p-tos">
Ces conditions seront régies et interprétées conformément aux lois du pays/juridiction où est basée la plateforme, sans égard à ses conflits de dispositions légales.
</p>
</div>
</template>
<style>
@import '@/cssstyle/tosstyle.css';
</style>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,78 @@
<template>
<div class="fixed z-50 bottom-6 right-6 flex flex-column">
<div
v-if="showPopup"
ref="popup"
class="z-50 shadow-md shadow-gray-500 rounded-2xl"
>
<div class="bg-fuchsia-900 p-4 rounded-t-2xl font-semibold self-center text-white text-center">
Je Soutiens!
</div>
<div class="bg-gray-100 rounded-b-2xl p-4">
<div class="mx-2">
<StripePayment :creator-id="creatorId"></StripePayment>
</div>
</div>
</div>
<div
@click="togglePopup"
ref="popupButton"
class="bg-purple rounded-full w-16 h-16 flex justify-center items-center self-end mt-4 cursor-pointer"
style="background: radial-gradient(circle, rgba(163,14,121,1) 50%, rgba(107,0,101,1) 100%); border: 2px solid white;"
>
<v-icon class="text-2xl">mdi-gift-outline</v-icon>
</div>
</div>
</template>
<script setup>
import {ref, onMounted, onUnmounted} from 'vue';
import StripePayment from "@/views/StripePayment.vue";
const showPopup = ref(false);
const popup = ref(null);
const popupButton = ref(null);
const props = defineProps({
creatorId: {type: String, required: true},
});
const togglePopup = () => {
showPopup.value = !showPopup.value;
};
const handleClickOutside = (event) => {
if (
popup.value &&
!popup.value.contains(event.target) &&
!popupButton.value.contains(event.target) &&
!event.target.closest('.bg-purple')
) {
showPopup.value = false;
}
};
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
</script>
<style scoped>
.bg-fuchsia-900 {
background-color: #9c27b0;
}
.bg-gray-100 {
background-color: #f5f5f5;
}
.bg-purple {
background-color: #9c27b0;
}
</style>

View File

@@ -0,0 +1,106 @@
<script setup>
import XIcon from '@/assets/icons/x.svg'
import FacebookIcon from '@/assets/icons/facebook.svg'
import InstagramIcon from '@/assets/icons/instagram.svg'
import {useBrandingStore} from "@/stores/brandingStore.js";
const brandingStore = useBrandingStore()
</script>
<template>
<footer class="py-8 flex flex-col gap-8" :style="{color: brandingStore.colors.onBackground}">
<div class="centered-text text-2xl font-bold flex justify-center items-center ml-28 lg:tracking-[125px] md:tracking-[50px] sm:tracking-[20px]">Hutopy</div>
<div class="flex flex-row justify-center gap-10">
<a href="https://www.facebook.com/profile.php?id=61556819217561">
<facebook-icon class="icon" :style="{ fill: brandingStore.colors.onBackground }" ></facebook-icon>
</a>
<a href="https://www.instagram.com/hutopy.inc/">
<instagram-icon class="icon" :style="{ fill: brandingStore.colors.onBackground }"></instagram-icon>
</a>
<a href="https://x.com/Hutopyinc/">
<x-icon class="icon" :style="{ fill: brandingStore.colors.onBackground }"></x-icon>
</a>
</div>
<div class="flex flex-row flex-wrap justify-center gap-4 px-4 " >
<router-link to="/documents/helpandcontact" :style="{color: brandingStore.colors.onBackground}">
{{ $t('footer.helpandcontact') }}
</router-link>
<router-link to="/documents/faq" :style="{color: brandingStore.colors.onBackground}">
{{ $t('footer.faq') }}
</router-link>
<router-link to="/documents/guideforcreators" :style="{color: brandingStore.colors.onBackground}">
{{ $t('footer.creatorguide') }}
</router-link>
<router-link to="/documents/termsandconditions" :style="{color: brandingStore.colors.onBackground}">
{{ $t('footer.termsandconditions') }}
</router-link>
<router-link to="/documents/contentpolicy" :style="{color: brandingStore.colors.onBackground}">
{{ $t('footer.contentpolicy') }}
</router-link>
<router-link to="/documents/about" :style="{color: brandingStore.colors.onBackground}">
{{ $t('footer.about') }}
</router-link>
<router-link to="/documents/pricing" :style="{color: brandingStore.colors.onBackground}">
{{ $t('footer.pricing') }}
</router-link>
</div>
<div class="flex justify-center base-text mb-13" :style="{color: brandingStore.colors.onBackground}">
Hutopy &copy;{{ new Date().getFullYear() }} - {{ $t('footer.allRightsReserved') }}
</div>
</footer>
</template>
<style scoped>
.icon {
width: 30px;
height: 30px;
}
.base-text {
@apply text-gray-600 tracking-widest font-sans text-sm uppercase
}
a {
@apply base-text
}
a:hover {
@apply text-gray-400
}
.centered-text {
display: flex;
justify-content: center;
align-items: center;
letter-spacing: 125px;
font-size: 2rem;
font-weight: bold;
margin-left: 7rem;
}
@media (max-width: 768px) {
.centered-text {
letter-spacing: 60px;
margin-left: 2rem;
}
}
@media (max-width: 640px) {
.centered-text {
letter-spacing: 40px;
margin-left: 1rem;
}
}
</style>

View File

@@ -0,0 +1,91 @@
<template>
<!-- <div class="bg-gray-100">-->
<!-- <div class="py-6">-->
<!-- <div class=" mx-auto flex justify-center">-->
<!-- <img src="/images/hutopymedia/banners/hutopy.png" alt="Hutopy Logo" class="h-24">-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="mx-auto flex justify-center pt-10 max-w-[980px]">-->
<!-- <img src="/images/hutopymedia/homepage/bannierehomepage.png" alt="Create CallToAction"-->
<!-- class="max-w-full block rounded-none md:rounded-2xl">-->
<!-- </div>-->
<!-- <div>-->
<!-- <div-->
<!-- class="mx-auto flex flex-col md:flex-row justify-center max-w-[1000px] space-y-2 md:space-x-4 md:space-y-0 py-5">-->
<!-- <div class="relative group w-full max-w-[250px] md:max-w-[306px] rounded-2xl overflow-hidden mx-auto">-->
<!-- <img src="/images/hutopymedia/homepage/creer.png" alt="Create CallToAction" class="w-full rounded-2xl">-->
<!-- <div-->
<!-- class="absolute inset-0 bg-fuchsia-600 bg-opacity-0 group-hover:bg-opacity-80 flex items-center justify-center transition duration-300">-->
<!-- <p class="text-white text-lg opacity-0 group-hover:opacity-100 transition duration-300 m-3 text-justify">-->
<!-- Libérez votre créativité sur Hutopy, chaque idée trouve sa place et chaque créateur détient la clé d'un-->
<!-- monde rempli de possibilités infinies. Rejoignez-nous et transformez votre passion en réalité.-->
<!-- </p>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="relative group w-full max-w-[250px] md:max-w-[306px] rounded-2xl overflow-hidden mx-auto">-->
<!-- <img src="/images/hutopymedia/homepage/partager.png" alt="Share CallToAction" class="w-full rounded-2xl">-->
<!-- <div-->
<!-- class="absolute inset-0 bg-fuchsia-600 bg-opacity-0 group-hover:bg-opacity-80 flex items-center justify-center transition duration-300">-->
<!-- <p class="text-white text-lg opacity-0 group-hover:opacity-100 transition duration-300 m-3 text-justify">-->
<!-- Plongez dans l'univers Hutopy et découvrez un espace profiter rime avec s'enrichir. Savourez des contenus-->
<!-- uniques, des interactions authentiques et une expérience personnalisée conçue pour éveiller vos sens et-->
<!-- enrichir votre quotidien.-->
<!-- </p>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="relative group w-full max-w-[250px] md:max-w-[306px] rounded-2xl overflow-hidden mx-auto">-->
<!-- <img src="/images/hutopymedia/homepage/inspirer.png" alt="Inspire CallToAction" class="w-full rounded-2xl">-->
<!-- <div-->
<!-- class="absolute inset-0 bg-fuchsia-600 bg-opacity-0 group-hover:bg-opacity-80 flex items-center justify-center transition duration-300">-->
<!-- <p class="text-white text-lg opacity-0 group-hover:opacity-100 transition duration-300 m-3 text-justify">-->
<!-- Devenez une source d'inspiration sur Hutopy, en partageant votre vision, votre talent et vos histoires.-->
<!-- Influencez positivement la communauté, éveillez la curiosité et inspirez les autres à poursuivre leurs rêves-->
<!-- dans un cercle vertueux de créativité et d'inspiration.-->
<!-- </p>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<div>
<!-- Main Content Section -->
<div class="max-w-4xl mx-auto px-6 py-8 space-y-6">
<img src="/images/hutopymedia/homepage/votrehutopy.png" alt="YourHutopy" class="mx-auto mb-8">
<div>
<div class="space-y-4">
<p class="text-lg leading-relaxed text-justify">
Notre plateforme offre à ses utilisateurs un espace dédié pour centraliser leurs réseaux sociaux sur leur page personnelle. Grâce à cette fonctionnalité, chaque utilisateur peut rediriger son audience vers ses différents comptes et sites web de manière simple et efficace.
De plus, notre site permet aux créateurs de recevoir des donations directement via leur page, leur offrant un moyen supplémentaire de soutien financier.
Ainsi, les utilisateurs peuvent facilement connecter leurs visiteurs à leur univers digital tout en renforçant leur communauté et en augmentant les interactions sur leurs autres plateformes de réseaux sociaux.
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.overlay p {
color: white;
font-size: 1.5rem;
text-align: center;
}
body {
background-color: #F4F4F4;
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<div class="flex flex-col items-center min-w-[300px] m-4">
<h1 class="text-center text-2xl font-bold mb-5">Connexion</h1>
<google-login class="w-full"
:callback="googleCallback"
popup-type="TOKEN">
<v-btn density="comfortable" class="mb-2 w-full">
<v-icon left>mdi-google</v-icon>
Google
</v-btn>
</google-login>
</div>
</template>
<script setup>
import {ref} from 'vue';
import {useAuthStore} from '@/stores/authStore.js';
import {GoogleLogin} from "vue3-google-login";
const authStore = useAuthStore();
const errorSnackBar = ref(false);
async function googleCallback(token) {
const response = await authStore.loginWithGoogle(JSON.stringify(token));
if (response !== true) {
errorSnackBar.value = true;
}
}
</script>

View File

@@ -0,0 +1,159 @@
<script setup>
import SubscriptionList from "@/views/creators/SubscriptionList.vue";
import { useAuthStore } from "@/stores/authStore.js";
import { useRouter } from 'vue-router';
import { computed, ref } from "vue";
import { useI18n } from 'vue-i18n';
import { useCreatorProfileStore } from "@/stores/creatorProfileStore.js";
import {useUserProfileStore} from "@/stores/userProfileStore.js";
import {useSideBarStore} from "@/stores/sideBarStore.js";
const {locale} = useI18n();
const router = useRouter();
const selectedLanguage = ref(locale.value);
const userProfileStore = useUserProfileStore();
const creatorProfileStore = useCreatorProfileStore();
const authStore = useAuthStore();
const sideBarStore = useSideBarStore();
const creatorIsCurrentUser = computed(() => authStore.isAuthenticated && authStore.userId);
const createHtmlContent = () => {
router.push('/content/editor');
};
function initializeLocale() {
const preferredLocale = localStorage.getItem('preferredLocale');
selectedLanguage.value = preferredLocale === null ? locale.value : preferredLocale;
locale.value = selectedLanguage.value;
}
function toggleLanguage() {
const lang = selectedLanguage.value === 'fr' ? 'en' : 'fr';
locale.value = lang;
selectedLanguage.value = lang;
localStorage.setItem('preferredLocale', lang);
}
function toggleMenu() {
sideBarStore.toggle();
}
initializeLocale();
</script>
<template>
<nav :class="['fixed flex flex-col h-full bg-white border-r border-gray-300', sideBarStore.isOpen ? 'max-w-64 px-4' : 'max-w-[82px] px-2']">
<!-- LOGO HUTOPY -->
<div class="mt-4" :class="sideBarStore.isOpen ? 'px-4' : 'px-2'">
<router-link to="/@hutopy">
<img v-if="sideBarStore.isOpen"
src="/images/hutopymedia/banners/hutopy.png"
alt="hutopy"
width="300px"
height="64px">
<img v-else
src="/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png"
alt="hutopy"
width="42px"
height="42px">
</router-link>
</div>
<!-- <div class="flex-grow mt-4" :class="sideBarStore.isOpen ? 'px-4' : 'px-2'">-->
<!-- <template v-if="authStore.isAuthenticated"> -->
<!-- <div class="font-bold" :class="{ 'text-center': !sideBarStore.isOpen }">-->
<!-- <span v-if="sideBarStore.isOpen">{{ $t('sidebar.subscriptionTitle') }}</span>-->
<!-- <span v-else>A</span>-->
<!-- </div>-->
<!-- <div-->
<!-- class="border-b border-gray-300 my-4 mx-auto"-->
<!-- :class="sideBarStore.isOpen ? 'w-48' : 'w-16'"-->
<!-- ></div>-->
<!-- <subscription-list v-if="sideBarStore.isOpen"></subscription-list>-->
<!-- </template>-->
<!-- </div>-->
<div
class="border-b border-gray-300 my-4 mx-auto"
:class="sideBarStore.isOpen ? 'w-48' : 'w-16'"
></div>
<div class="flex-grow"></div>
<!-- SECTION UTILISATEUR -->
<div :class="sideBarStore.isOpen ? 'px-4' : 'px-2'">
<div class="flex items-center justify-start p-2 mb-4" :class="!sideBarStore.isOpen ? 'my-2' : ''">
<img
:src="userProfileStore.portraitUrl"
alt="Profile Image"
referrerpolicy="no-referrer"
class="rounded-full"
width="42"
height="42"
style="max-height: 42px;">
<span v-if="sideBarStore.isOpen" class="ml-2 text-sm font-sans capitalize">
{{ userProfileStore.alias }}
</span>
</div>
<div class="flex flex-col gap-4 mb-4">
<!-- <router-link v-if="creatorProfileStore.hasCreator" :to="`/content/editor`">-->
<!-- <v-btn class="w-full justify-start" prepend-icon="mdi-pencil" variant="flat" :class="!sideBarStore.isOpen ? 'my-2' : ''">-->
<!-- <span v-if="sideBarStore.isOpen">Éditeur</span>-->
<!-- </v-btn>-->
<!-- </router-link>-->
<router-link v-if="creatorProfileStore.hasCreator" :to="`/@${creatorProfileStore.creator.name}`">
<v-btn class="w-full justify-start" prepend-icon="mdi-home-account" variant="flat" :class="!sideBarStore.isOpen ? 'my-2' : ''">
<span v-if="sideBarStore.isOpen">{{ creatorProfileStore.creator.name }}</span>
</v-btn>
</router-link>
<router-link v-else-if="authStore.isAuthenticated"
class="w-full justify-start"
to="/create-creator">
<v-btn class="w-full" variant="plain" :class="!sideBarStore.isOpen ? 'my-2' : ''">
<span v-if="sideBarStore.isOpen">Activer votre page</span>
</v-btn>
</router-link>
<div v-if="authStore.isAuthenticated">
<v-btn to="/profile" class="w-full justify-start" prepend-icon="mdi-account" variant="flat" :class="!sideBarStore.isOpen ? 'my-2' : ''">
<span v-if="sideBarStore.isOpen">{{ $t('header.myprofile') }}</span>
</v-btn>
</div>
<v-btn variant="flat" class="w-full justify-start" prepend-icon="mdi-translate-variant" @click="toggleLanguage" :class="!sideBarStore.isOpen ? 'my-2' : ''">
<span v-if="sideBarStore.isOpen">{{ $t('language.language') }}</span>
</v-btn>
<v-btn
@click="toggleMenu"
variant="flat"
class="w-full justify-start"
:prepend-icon="sideBarStore.isOpen ? 'mdi-arrow-collapse-left' : 'mdi-arrow-collapse-right'"
:class="!sideBarStore.isOpen ? 'my-2' : ''"
>
<span v-if="sideBarStore.isOpen">{{ $t('sidebar.Reduce') }}</span>
</v-btn>
<div v-if="!authStore.isAuthenticated">
<v-btn to="/login" variant="flat" class="justify-start" prepend-icon="mdi-login" :class="!sideBarStore.isOpen ? 'my-2' : ''">
<span v-if="sideBarStore.isOpen">{{ $t('sidebar.connection') }}</span>
</v-btn>
</div>
<div v-else>
<v-btn @click="authStore.logout" variant="flat" class="justify-start" prepend-icon="mdi-logout" :class="!sideBarStore.isOpen ? 'my-2' : ''">
<span v-if="sideBarStore.isOpen">{{ $t('header.Signout') }}</span>
</v-btn>
</div>
</div>
</div>
</nav>
</template>

View File

@@ -0,0 +1,121 @@
<template>
<v-container class="mt-10 bg-gray-100 py-10 rounded-lg shadow-lg border border-fuchsia-500 mb-15">
<div class="flex justify-center text-6xl mb-12 font-sans font-weight-bold">Portefeuille</div>
<div class="flex justify-between mb-4">
<div class="text-left">
<span class="font-bold">Montant Total : {{ formattedBalance }}</span>
</div>
<div class="text-right">
<span class="font-bold">Transactions total : {{ transactionCount }}</span>
</div>
</div>
<v-data-table
:headers="headers"
:items="formattedTransactions"
class="elevation-1 text-black"
:items-per-page="5"
show-group-by
>
</v-data-table>
<div class="flex justify-end mt-4">
<v-btn icon @click="openModal">
<v-icon class="text-[#A30E79]">mdi-information</v-icon>
</v-btn>
</div>
<v-dialog v-model="isModalOpen" max-width="500px">
<v-card>
<v-card-title>
Tarification
</v-card-title>
<v-card-text class="scrollable-content">
Découvrez Hutopy, l'endroit où la valorisation de votre travail atteint son apogée. Avec une commission réduite à seulement 9 %, notre engagement envers votre succès est palpable. Chaque pourcentage prélevé est réinvesti avec soin pour catalyser votre croissance : du développement de fonctionnalités innovantes à la maintenance d'une infrastructure technologique de pointe, en passant par un support utilisateur de premier ordre. Notre objectif ? Amplifier votre expansion et garantir une expérience utilisateur sans précédent.
Pour chaque transaction, un frais minime assure la sécurité et la fiabilité de vos paiements, grâce à un partenaire de confiance à la renommée mondiale. Ce dernier sécurise pour des milliards en transaction chaque année pour une diversité d'entreprises, allant des startups dynamiques aux conglomérats établis. Ce gage de sécurité est disponible pour une modique somme : 2,9 % plus 0,30 $ par transaction, une petite contribution pour la tranquillité d'esprit et la protection de vos revenus.
Notre modèle tarifaire a été pensé dans un esprit de simplicité et de transparence, avec l'ambition ultime d'optimiser vos gains. Chez Hutopy, la notion de partenariat prend tout son sens : votre épanouissement est au cœur de nos préoccupations. Bénéficiez d'une plateforme qui élargit votre horizon créatif et entrepreneurial, tout en vous assurant que vos intérêts sont précieusement gardés.
Hutopy est plus qu'une plateforme ; c'est une communauté où la transformation de la passion en profit devient réalité, grâce au soutien indéfectible d'une équipe dévouée à enrichir votre parcours. Nous vous invitons à nous rejoindre pour explorer ensemble les avenues de succès que nous pouvons emprunter ensemble, tout en vous garantissant une part conséquente de vos revenus. Embarquez dans une aventure votre présence en ligne ne connaît pas de limites, soutenue par Hutopy, votre allié dans la quête du succès.
</v-card-text>
<v-card-actions>
<v-btn text class="ml-auto" @click="isModalOpen = false">Fermer</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<div class="flex justify-center mt-4 ">
<v-btn text class="transparent-btn text-lg px-12" @click="navigateToHome">Retour</v-btn>
</div>
</v-container>
</template>
<script async setup>
import { onBeforeMount, ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import {useUserProfileStore} from "@/stores/userProfileStore.js";
const userProfileStore = useUserProfileStore();
const router = useRouter();
const userTransactions = ref([]);
const totalBalance = ref("");
const isModalOpen = ref(false);
const formattedTransactions = computed(() => {
return userTransactions.value.map(transaction => ({
...transaction,
created: transaction.created.split('T')[0]
}));
});
const formattedBalance = computed(() => {
const balance = totalBalance.value.toString();
return `${balance} $`
});
const transactionCount = computed(() => userTransactions.value.length);
onBeforeMount( () => {
try {
userTransactions.value = userProfileStore.value.userTransactions;
totalBalance.value = userProfileStore.value.totalBalance;
} catch (error) {
navigateToHome();
}
});
const headers = ref([
{ title: 'Montant', value: 'amount', width: '20%', key: "amount" },
{ title: 'Date', value: 'created', width: '20%', key: "created" },
{ title: 'Message', value: 'tipMessage', width: '60%' }
]);
const navigateToHome = () => {
router.push('/');
};
const openModal = () => {
isModalOpen.value = true;
};
</script>
<style scoped>
.scrollable-content {
max-height: 600px;
overflow-y: auto;
}
.scrollable-content::-webkit-scrollbar {
width: 6px;
}
.scrollable-content::-webkit-scrollbar-thumb {
background-color: #A30E79;
border-radius: 10px;
}
.scrollable-content::-webkit-scrollbar-thumb:hover {
background-color: #a21caf;
}
.transparent-btn {
background-color: transparent;
color: inherit;
box-shadow: none;
}
</style>

View File

@@ -0,0 +1,186 @@
<template>
<div class="flex flex-column py-2">
<div class="flex flex-row full">
<div class="w-full">
<div class="flex justify-between items-center">
<div>
<div>
<div class="content-center flex flex-row">
<img
:src="message.createdByPortraitUrl ?? '/images/usersmedia/anonyme/profilepictures/profileAnonymeSquare.png'"
alt="Profile Image"
class="rounded-full"
width="32px"
height="32px"
/>
<span class="font-semibold font-sans mr-2 capitalize ml-2">
{{ message.createdByName }}
</span>
<v-tooltip :text="new Date(message.createdAt).toLocaleString()">
<template v-slot:activator="{ props }">
<span v-bind="props" class="text-sm-caption text-gray-700 mt-1 ">
{{ time_ago(message.createdAt) }}
</span>
</template>
</v-tooltip>
</div>
</div>
</div>
<v-menu class="ml-auto" v-if="messageAuthorIsCurrentUser">
<template v-slot:activator="{ props }">
<v-btn variant="plain" icon v-bind="props">
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item @click="editMessage(message)">
<v-list-item-title>{{ $t('message.edit') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="deleteMessage()">
<v-list-item-title>{{ $t('message.delete') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<div class="font-sans message-content">
<p class="pb-2" v-if="!isEditMessage"> {{ message.value }}</p>
<div v-if="isEditMessage" class="flex flex-row">
<v-textarea
variant="outlined"
v-model="editMessageValue"
rows="1"
auto-grow
class="flex-1 mt-3"
@keyup.enter="acceptChanges"
></v-textarea>
<div class="flex flex-row px-2 space-y-1 align-center">
<v-btn variant="plain" @click="cancel">
<v-icon class="rounded-full">mdi-cancel</v-icon>
</v-btn>
<v-btn variant="plain" @click="acceptChanges">
<v-icon>mdi-check</v-icon>
</v-btn>
</div>
</div>
</div>
</div>
</div>
</div>
<must-be-logged v-model="loginModal"
message="Vous devez être connecté pour supprimer ou modifier un commentaire."></must-be-logged>
</template>
<script setup>
import {ref, watch, onMounted, onBeforeUnmount, computed} from "vue";
import {time_ago} from "@/internal_time_ago.js";
import MustBeLogged from "@/views/MustBeLogged.vue";
import {useAuthStore} from "@/stores/authStore.js";
import {useClient} from "@/plugins/api.js";
const isEditMessage = ref(false);
const editMessageValue = ref("");
const originalMessageValue = ref("");
const loginModal = ref(false);
const authStore = useAuthStore();
const client = useClient();
const messageAuthorId = computed(() => props.message.createdBy)
const messageAuthorIsCurrentUser = computed(() => authStore.isAuthenticated && authStore.userId === messageAuthorId.value)
const props = defineProps({
message: {
type: Object,
required: true
}
});
const emits = defineEmits(['message-deleted']);
function editMessage(message) {
isEditMessage.value = true;
originalMessageValue.value = message.value;
editMessageValue.value = message.value;
}
const acceptChanges = async () => {
props.message.value = editMessageValue.value;
isEditMessage.value = false;
console.log('Update message', props.message.value);
if (!authStore.isAuthenticated) {
loginModal.value = true;
} else {
try {
await client.post(`/api/messages/update`, {
"id": props.message.id,
"subjectId": props.message.subjectId,
"message": props.message.value
})
} catch (error) {
console.error(`post api/message/update : ${error}`)
}
}
}
function cancel() {
editMessageValue.value = originalMessageValue.value;
isEditMessage.value = false;
}
const deleteMessage = async () => {
console.log('Delete message', props.message);
if (!authStore.isAuthenticated) {
loginModal.value = true;
} else {
try {
await client.delete(`/api/messages/${props.message.id}`)
emits('message-deleted', {
"id": props.message.id
})
} catch (error) {
console.error(`delete api/message : ${error}`)
}
}
}
function handleKeydown(event) {
if (event.key === "Escape") {
cancel();
}
}
watch(isEditMessage, (newValue) => {
if (newValue) {
window.addEventListener('keydown', handleKeydown);
} else {
window.removeEventListener('keydown', handleKeydown);
}
});
onMounted(() => {
if (isEditMessage.value) {
window.addEventListener('keydown', handleKeydown);
}
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeydown);
});
</script>
<style scoped>
.content-center {
display: flex;
align-items: center;
}
.message-content {
word-wrap: break-word;
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<v-infinite-scroll
:items="messages"
:onLoad="fetchMessages"
mode="manual"
class="justify-items-center"
>
<template v-for="message in messages" :key="message">
<div class="border-b">
<message :message="message"
@message-deleted="(messageId) => handleDeleteMessage(messageId)"
></message>
</div>
</template>
<template v-slot:load-more="{ props }">
<v-btn size="small" variant="outlined" v-bind="props">
Voir plus de commentaires
</v-btn>
</template>
<template v-slot:empty>
Il n'y a pas plus de commentaires
</template>
<template v-slot:error>
<v-alert type="error">{{ errorMessage }}</v-alert>
</template>
</v-infinite-scroll>
</template>
<script setup>
import {ref, onBeforeMount} from 'vue';
import {useClient} from '@/plugins/api.js';
import Message from "@/views/messages/Message.vue";
const props = defineProps({
subjectId: {
type: String,
required: true,
},
messages: {
type: Array,
default: () => [],
},
});
const errorMessage = ref(null);
let last_id = null;
const client = useClient();
const messages = ref(props.messages);
onBeforeMount(async () => {
if (props.subjectId == null) return;
await fetchMessages({
page_size: 2,
done: function (status) {
},
});
});
async function fetchMessages({done, page_size = 10}) {
if (props.subjectId == null) return
try {
let uri = `/api/messages/${props.subjectId}?page_size=${page_size}`;
if (last_id !== null) uri = uri + `&last_id=${last_id}`;
const response = await client.get(uri);
if (response.status >= 200 && response.status < 300) {
const messageCount = response.data.messages.length;
if (messageCount > 0) {
messages.value.push(...response.data.messages);
const [last_content] = response.data.messages.slice(-1);
last_id = last_content.id;
}
if (messageCount < page_size) {
done('empty');
} else {
done('ok');
}
}
} catch (error) {
console.error("Failed to fetch messages", error);
errorMessage.value = error.message || "Failed to fetch messages";
done('error');
}
}
function handleDeleteMessage(message) {
messages.value = messages.value.filter(item => item.id !== message.id);
}
</script>

View File

@@ -0,0 +1,113 @@
<template>
<div class="flex flex-column">
<div class="flex flex-row items-center ">
<img :src="userProfileStore.portraitUrl" alt="Profile Image" class="rounded-full mr-2" width="32px" height="32px">
<div class="flex-grow">
<div class="flex flex-row bg-gray-100 rounded-2xl">
<v-textarea
v-model="value"
density="compact"
variant="underlined"
:placeholder="$t('message.yourcomment')"
hide-details
auto-grow
rows="1"
maxlength="1024"
class="pr-1 ml-6 flex-grow"
@keydown.enter.prevent="publish"
>
</v-textarea>
<div class="flex flex-col justify-center">
<v-btn
icon
variant="text"
@click="publish"
>
<v-icon>mdi-send</v-icon>
</v-btn>
</div>
</div>
</div>
</div>
<div class="flex justify-end items-center mt-1">
<div v-if="value.length < 1024" class="text-gray-500 text-sm">{{ value.length }}/1024</div>
<div v-if="value.length >= 1024" class="text-red-500 text-sm">{{ value.length }}/1024</div>
</div>
</div>
<must-be-logged v-model="loginModal"
message="Vous devez être connecté pour ajouter un commentaire."
></must-be-logged>
</template>
<script setup>
import {ref} from 'vue'
import {v7} from 'uuid'
import {useClient} from '@/plugins/api.js'
import {useUserProfileStore} from "@/stores/userProfileStore.js"
import {useAuthStore} from "@/stores/authStore.js"
import MustBeLogged from "@/views/MustBeLogged.vue";
const props = defineProps({
subjectId: {
type: String,
required: true
}
});
const emits = defineEmits(['message-posted'])
const loginModal = ref(false);
const client = useClient()
const value = ref("")
const userProfileStore = useUserProfileStore()
const authStore = useAuthStore()
const publish = async () => {
if (!authStore.isAuthenticated) {
loginModal.value = true;
} else {
try {
const messageId = v7()
await client.post(`/api/messages/`, {
"id": messageId,
"subjectId": props.subjectId,
"message": value.value
})
emits('message-posted', {
"id": messageId,
"subjectId": props.subjectId,
"createdBy": userProfileStore.user.id,
"createdByName": userProfileStore.alias,
"createdByPortraitUrl": userProfileStore.portraitUrl,
"createdAt": new Date(Date.now()).toISOString(),
"value": value.value,
"parentId": null
})
value.value = ''
} catch (error) {
console.error(`post api/message : ${error}`)
}
}
}
</script>
<style scoped>
.text-red-500 {
color: #f56565;
}
.text-gray-500 {
color: #a0aec0;
}
.text-sm {
font-size: 0.875rem;
}
.mt-1 {
margin-top: 0.25rem;
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<!-- Mobile -->
<div v-if="isMobileView" class="flex flex-col items-center justify-center min-h-screen bg-gray-100 p-6 text-center">
<!-- Image -->
<img src="/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png" alt="Image" class="w-64 h-64 rounded-full mb-4 border" />
<!-- Message -->
<div class="text-lg text-gray-700 mt-8">
<p class="font-semibold mb-2">Pour vous connecter et modifier votre page, veuillez utiliser un appareil avec un écran plus large, comme un ordinateur.</p>
<p>Pour le moment, l'expérience sur téléphone n'est pas encore complétée.</p>
<p class="mt-4 font-bold">Désolé de l'inconvénient.</p>
</div>
</div>
<!-- PC -->
<div v-else>
<div class="flex flex-col md:flex-row bg-[#f4f4f4] h-full">
<!-- Left Menu -->
<div class=" z-20 w-full md:max-w-xs fixed md:sticky md:top-0 md:flex md:flex-col top-0">
<div class="sticky top-20 z-30">
<div class="flex flex-col items-center md:items-start md:pl-4 mt-16">
<h1 class="text-2xl py-4 font-bold text-center md:text-left">{{$t('profilemenu.manageyouraccount')}}</h1>
<div class="relative flex items-center md:mt-0 w-full">
<!-- Navigation buttons for small screens -->
<button @click="scrollLeftFunc"
class="rounded p-1 absolute left-2 z-10 md:hidden text-fuchsia-800 text-2xl ">
<v-icon>mdi-chevron-left</v-icon>
</button>
<div
ref="scrollContainer"
class="flex md:flex-col space-x-2 space-y-0 md:space-x-0 md:space-y-2 p-4 items-center md:items-start overflow-x-scroll md:overflow-x-visible mx-2 md:mx-0 custom-scroll min-w-[400px] px-1"
@mousedown="mouseDown"
@mouseleave="mouseLeave"
@mouseup="mouseUp"
@mousemove="mouseMove">
<v-btn variant="text" @click="currentComponent = 'CreatorPage'">
<v-icon class="mr-2">mdi-file-edit-outline</v-icon>
{{ $t('profilemenu.creator') }}
</v-btn>
<v-btn variant="text" @click="currentComponent = 'AccountPage'">
<v-icon class="mr-2">mdi-information</v-icon>
{{ $t('profilemenu.user') }}
</v-btn>
</div>
<button @click="scrollRightFunc"
class="rounded p-1 absolute right-2 z-10 md:hidden text-fuchsia-800 bg-[#f4f4f4] text-2xl">
<v-icon>mdi-chevron-right</v-icon>
</button>
</div>
</div>
</div>
</div>
<!-- Mid Content -->
<div class="flex flex-col flex-1 align-center py-12 p-3 mt-28 md:mt-0">
<template v-if="currentComponent === 'CreatorPage'">
<creator-page></creator-page>
</template>
<template v-else-if="currentComponent === 'AccountPage'">
<account-page></account-page>
</template>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from "vue";
import CreatorPage from "@/views/profile/creators/CreatorPage.vue";
import AccountPage from "@/views/profile/account/AccountPage.vue";
import { useRoute } from "vue-router";
import { useDisplay } from "vuetify";
const { smAndDown } = useDisplay();
const route = useRoute();
const startingComponent = route.query.target || 'CreatorPage';
const currentComponent = ref(startingComponent);
const isMobileView = ref(smAndDown.value);
watch(smAndDown, (newVal) => {
isMobileView.value = newVal;
});
// Gestion du slider (scroll sur petit écran)
const isDown = ref(false);
const startX = ref(0);
const scrollLeft = ref(0);
const mouseDown = (e) => {
const slider = document.querySelector('.custom-scroll');
isDown.value = true;
slider.classList.add('active');
startX.value = e.pageX - slider.offsetLeft;
scrollLeft.value = slider.scrollLeft;
};
const mouseLeave = () => {
isDown.value = false;
const slider = document.querySelector('.custom-scroll');
slider.classList.remove('active');
};
const mouseUp = () => {
isDown.value = false;
const slider = document.querySelector('.custom-scroll');
slider.classList.remove('active');
};
const mouseMove = (e) => {
if (!isDown.value) return;
e.preventDefault();
const slider = document.querySelector('.custom-scroll');
const x = e.pageX - slider.offsetLeft;
const walk = (x - startX.value) * 3; // scroll-fast
slider.scrollLeft = scrollLeft.value - walk;
};
const scrollLeftFunc = () => {
const container = document.querySelector('.custom-scroll');
container.scrollBy({ left: -100, behavior: 'smooth' });
};
const scrollRightFunc = () => {
const container = document.querySelector('.custom-scroll');
container.scrollBy({ left: 100, behavior: 'smooth' });
};
</script>
<style scoped>
.custom-scroll {
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */
}
.custom-scroll::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
</style>

View File

@@ -0,0 +1,299 @@
<template>
<div class="flex flex-col items-center w-[800px] gap-4">
<h1 class="uppercase pb-5 text-2xl">
<v-icon class="mr-2">mdi-information</v-icon>
{{ $t('personnalinformation.title') }}
</h1>
<v-card class="w-full">
<v-card-title>
{{ $t('personnalinformation.informations') }}
</v-card-title>
<!-- <button-->
<!-- class="editableValue"-->
<!-- @click="openEditPortrait">-->
<!-- <span class="label">{{ $t('personnalinformation.profilepicture') }}</span>-->
<!-- <span class="value">Un portrait vous permet de personnaliser votre profil</span>-->
<!-- <span>-->
<!-- <img-->
<!-- :src="userProfileStore.user.portraitUrl"-->
<!-- alt="Profile Image"-->
<!-- class="rounded-full"-->
<!-- width="48px"-->
<!-- height="48px"/>-->
<!-- </span>-->
<!-- </button>-->
<button
class="editableValue"
@click="openEditFullname">
<span class="label">{{ $t('personnalinformation.fullname') }}</span>
<span class="value">{{ userProfileStore.fullname }}</span>
<span><v-icon>mdi-chevron-right</v-icon></span>
</button>
<button
class="editableValue"
@click="openEditAlias">
<span class="label">{{ $t('personnalinformation.alias') }}</span>
<span class="value">{{ userProfileStore.user.alias }}</span>
<span><v-icon>mdi-chevron-right</v-icon></span>
</button>
<!-- <button-->
<!-- class="editableValue"-->
<!-- @click="openEditBirthday">-->
<!-- <span class="label">{{ $t('personnalinformation.dob') }}</span>-->
<!-- <span class="value">{{ userProfileStore.user.birthDate }}</span>-->
<!-- <span><v-icon>mdi-chevron-right</v-icon></span>-->
<!-- </button>-->
</v-card>
<!-- Phone & email -->
<v-card class="w-full">
<v-card-title>
{{ $t('personnalinformation.contactdetails') }}
</v-card-title>
<button
class="editableValue"
@click="openEditEmail">
<span class="label">{{ $t('personnalinformation.email') }}</span>
<span class="value">{{ userProfileStore.user.email }}</span>
<span><v-icon>mdi-chevron-right</v-icon></span>
</button>
<!-- <button-->
<!-- class="editableValue"-->
<!-- @click="openEditPhone">-->
<!-- <span class="label">{{ $t('personnalinformation.phone') }}</span>-->
<!-- <span class="value">{{ userProfileStore.user.phoneNumber }}</span>-->
<!-- <span><v-icon>mdi-chevron-right</v-icon></span>-->
<!-- </button>-->
</v-card>
<!-- Address -->
<!-- <v-card class="w-full">-->
<!-- <v-card-title>-->
<!-- {{ $t('personnalinformation.addresses') }}-->
<!-- </v-card-title>-->
<!-- <button-->
<!-- class="editableValue"-->
<!-- @click="openEditAddress">-->
<!-- <span class="label">{{ $t('personnalinformation.home') }}</span>-->
<!-- <span class="value">{{ userProfileStore.user.address }}</span>-->
<!-- <span><v-icon>mdi-chevron-right</v-icon></span>-->
<!-- </button>-->
<!-- </v-card>-->
</div>
<!-- Modal -->
<v-dialog v-model="dialogEditPortraitShown" max-width="600px">
<portrait-dialog
:portrait-url="userProfileStore.user.portraitUrl"
@close="handleCloseEditPortrait"
@save="handleSaveEditPortrait"
></portrait-dialog>
</v-dialog>
<v-dialog v-model="dialogEditFullnameShown" max-width="600px">
<fullname-dialog
:firstname="userProfileStore.user.firstname"
:lastname="userProfileStore.user.lastname"
@close="handleCloseEditFullname"
@save="handleSaveEditFullname"
></fullname-dialog>
</v-dialog>
<v-dialog v-model="dialogEditAliasShown" max-width="600px">
<alias-dialog
:alias="userProfileStore.user.alias"
@close="handleCloseEditAlias"
@save="handleSaveEditAlias"
></alias-dialog>
</v-dialog>
<v-dialog v-model="dialogEditBirthdayShown" max-width="600px">
<birthday-dialog
:birth-date="userProfileStore.user.birthDate"
@close="handleCloseEditBirthday"
@save="handleSaveEditBirthday"
></birthday-dialog>
</v-dialog>
<v-dialog v-model="dialogEditPhoneShown" max-width="600px">
<phone-dialog
:phone="userProfileStore.user.phoneNumber"
@close="handleCloseEditPhone"
@save="handleSaveEditPhone"
></phone-dialog>
</v-dialog>
<v-dialog v-model="dialogEditEmailShown" max-width="600px">
<email-dialog
:email="userProfileStore.user.email"
@close="handleCloseEditEmail"
@save="handleSaveEditEmail"
></email-dialog>
</v-dialog>
<v-dialog v-model="dialogEditAddressShown" max-width="600px">
<address-dialog
:address="userProfileStore.user.address"
@close="handleCloseEditAddress"
@save="handleSaveEditAddress"
></address-dialog>
</v-dialog>
</template>
<script setup>
import {ref} from 'vue';
import AddressDialog from './AddressDialog.vue';
import EmailDialog from "./EmailDialog.vue";
import PhoneDialog from "@/views/profile/account/PhoneDialog.vue";
import BirthdayDialog from "@/views/profile/account/BirthdayDialog.vue";
import AliasDialog from "@/views/profile/account/AliasDialog.vue";
import FullnameDialog from "@/views/profile/account/FullnameDialog.vue";
import PortraitDialog from "@/views/profile/account/PortraitDialog.vue";
import {useUserProfileStore} from "@/stores/userProfileStore.js";
const userProfileStore = useUserProfileStore()
// ### Portrait
const dialogEditPortraitShown = ref(false)
function openEditPortrait() {
dialogEditPortraitShown.value = true
}
function handleCloseEditPortrait() {
dialogEditPortraitShown.value = false
}
function handleSaveEditPortrait(portraitData) {
userProfileStore.changePortrait(portraitData)
dialogEditPortraitShown.value = false
}
// ### Fullname
const dialogEditFullnameShown = ref(false)
function openEditFullname() {
dialogEditFullnameShown.value = true
}
function handleCloseEditFullname() {
dialogEditFullnameShown.value = false
}
function handleSaveEditFullname(firstname, lastname) {
userProfileStore.changeFullname(firstname, lastname)
dialogEditFullnameShown.value = false
}
// ### Alias
const dialogEditAliasShown = ref(false)
function openEditAlias() {
dialogEditAliasShown.value = true
}
function handleCloseEditAlias() {
dialogEditAliasShown.value = false
}
function handleSaveEditAlias(alias) {
userProfileStore.changeAlias(alias)
dialogEditAliasShown.value = false
}
// ### Birthday
const dialogEditBirthdayShown = ref(false)
function openEditBirthday() {
dialogEditBirthdayShown.value = true
}
function handleCloseEditBirthday() {
dialogEditBirthdayShown.value = false
}
function handleSaveEditBirthday(birthday) {
userProfileStore.changeBirthday(birthday)
dialogEditBirthdayShown.value = false
}
// ### Phone
const dialogEditPhoneShown = ref(false)
function openEditPhone() {
dialogEditPhoneShown.value = true
}
function handleCloseEditPhone() {
dialogEditPhoneShown.value = false
}
function handleSaveEditPhone(phone) {
userProfileStore.changePhone(phone)
dialogEditPhoneShown.value = false
}
// ### Email
const dialogEditEmailShown = ref(false)
function openEditEmail() {
dialogEditEmailShown.value = true
}
function handleCloseEditEmail() {
dialogEditEmailShown.value = false
}
function handleSaveEditEmail(email) {
userProfileStore.changeEmail(email)
dialogEditEmailShown.value = false
}
// ### ADDRESS
const dialogEditAddressShown = ref(false)
function openEditAddress() {
dialogEditAddressShown.value = true
}
function handleCloseEditAddress() {
dialogEditAddressShown.value = false
}
function handleSaveEditAddress(address) {
userProfileStore.changeAddress(address)
dialogEditAddressShown.value = false
}
</script>
<style>
.editableValue {
@apply py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full;
@apply hover:bg-[#A6147D] hover:text-white hover:opacity-90;
}
.label {
@apply p-2 min-w-40 text-left;
}
.value {
@apply flex-auto pr-6 text-left;
}
</style>

View File

@@ -0,0 +1,36 @@
<template>
<v-card>
<v-card-title>
Adresse
</v-card-title>
<div class="m-4">
<v-text-field
variant="outlined"
v-model="address"
label="Votre adresse"
></v-text-field>
</div>
<v-card-actions>
<v-btn variant="plain" @click="requestClose">
Annuler
</v-btn>
<v-btn color="#A6147D" @click="requestSave">
Enregistrer
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import {ref} from 'vue';
const props = defineProps(['address'])
const emit = defineEmits(['close', 'save'])
const address = ref(props.address);
const requestClose = () => emit('close')
const requestSave = () => emit('save', address.value)
</script>

View File

@@ -0,0 +1,38 @@
<template>
<v-card>
<v-card-title>
{{ $t('personnalinformation.alias') }}
</v-card-title>
<div class="m-4">
<v-text-field
variant="outlined"
v-model="alias"
:label="$t('personnalinformation.alias')"
></v-text-field>
</div>
<v-card-actions>
<v-btn variant="plain" @click="requestClose">
Annuler
</v-btn>
<v-btn color="#A6147D" @click="requestSave">
Enregistrer
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import {ref} from 'vue';
const props = defineProps(['alias'])
const emit = defineEmits(['close', 'save'])
const alias = ref(props.alias)
const requestClose = () => emit('close')
const requestSave = () => emit('save', alias.value)
</script>

View File

@@ -0,0 +1,36 @@
<template>
<v-card>
<v-card-title>
Date de naissance
</v-card-title>
<div class="m-4">
<v-text-field
variant="outlined"
v-model="birthDate"
label="AAAA-MM-JJ"
></v-text-field>
</div>
<v-card-actions>
<v-btn variant="plain" @click="requestClose">
Annuler
</v-btn>
<v-btn color="#A6147D" @click="requestSave">
Enregistrer
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import {ref} from 'vue';
const props = defineProps(['birthDate'])
const emit = defineEmits(['close', 'save'])
const birthDate = ref(props.birthDate)
const requestClose = () => emit('close')
const requestSave = () => emit('save', birthDate.value)
</script>

View File

@@ -0,0 +1,38 @@
<template>
<v-card>
<v-card-title>
Courriel
</v-card-title>
<div class="m-4">
<v-text-field
variant="outlined"
v-model="email"
label="Votre courriel"
></v-text-field>
</div>
<v-card-actions>
<v-btn variant="plain" @click="requestClose">
Annuler
</v-btn>
<v-btn color="#A6147D" @click="requestSave">
Enregistrer
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import {ref} from 'vue';
const props = defineProps(['email'])
const emit = defineEmits(['close', 'save'])
const email = ref(props.email)
const requestClose = () => emit('close')
const requestSave = () => emit('save', email.value)
</script>

View File

@@ -0,0 +1,45 @@
<template>
<v-card>
<v-card-title>
{{ $t('personnalinformation.fullname') }}
</v-card-title>
<div class="m-4">
<v-text-field
variant="outlined"
v-model="firstname"
:label="$t('personnalinformation.firstname')"
></v-text-field>
<v-text-field
variant="outlined"
v-model="lastname"
:label="$t('personnalinformation.lastname')"
></v-text-field>
</div>
<v-card-actions>
<v-btn variant="plain" @click="requestClose">
Annuler
</v-btn>
<v-btn color="#A6147D" @click="requestSave">
Enregistrer
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import {ref} from 'vue';
const props = defineProps(['firstname', 'lastname'])
const emit = defineEmits(['close', 'save'])
const firstname = ref(props.firstname)
const lastname = ref(props.lastname)
const requestClose = () => emit('close')
const requestSave = () => emit('save', firstname.value, lastname.value)
</script>

View File

@@ -0,0 +1,37 @@
<template>
<v-card>
<v-card-title>
Numéro de téléphone
</v-card-title>
<div class="m-4">
<v-text-field
variant="outlined"
v-model="phone"
label="Votre numéro de téléphone"
></v-text-field>
</div>
<v-card-actions>
<v-btn variant="plain" @click="requestClose">
Annuler
</v-btn>
<v-btn color="#A6147D" @click="requestSave">
Enregistrer
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import {ref} from 'vue';
const props = defineProps(['phone'])
const emit = defineEmits(['close', 'save'])
const phone = ref(props.phone)
const requestClose = () => emit('close')
const requestSave = () => emit('save', phone.value)
</script>

View File

@@ -0,0 +1,58 @@
<template>
<v-card>
<v-card-title>
Portrait
</v-card-title>
<div class="m-4">
<img
:src="portraitData"
class="mb-5 w-full transition duration-200 ease-in-out transform"
alt="Aperçu de la bannière"
/>
<v-file-input
@change="onSelectedFileChanged"
v-model="selectedFile"
variant="outlined"
accept="image/*"
label="Votre bannière"
></v-file-input>
</div>
<v-card-actions>
<v-btn variant="plain" @click="requestClose">
Annuler
</v-btn>
<v-btn color="#A6147D" @click="requestSave">
Enregistrer
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import {ref} from 'vue';
const props = defineProps(['portraitUrl'])
const emit = defineEmits(['close', 'save'])
const portraitData = ref(props.portraitUrl)
const selectedFile = ref({})
const onSelectedFileChanged = () => {
if (selectedFile.value) {
const reader = new FileReader()
reader.onload = (event) => {
portraitData.value = event.target.result
}
reader.readAsDataURL(selectedFile.value)
} else {
portraitData.value = null
}
}
const requestClose = () => emit('close')
const requestSave = () => emit('save', selectedFile.value)
</script>

View File

@@ -0,0 +1,75 @@
<template>
<h2 class="text-2xl font-semibold mb-4 flex justify-center">
Bannière
</h2>
<img
:src="fileUrl || fallbackUrl"
class="mb-5 w-full transition duration-200 ease-in-out transform"
alt="Aperçu de la bannière"
>
<v-file-input
v-model="selectedFile"
variant="outlined"
accept="image/*"
label="Votre bannière"
@change="onFileSelected"
></v-file-input>
<div class="flex justify-end space-x-4">
<v-btn color="black" variant="text" @click="cancel">Annuler</v-btn>
<v-btn color="#A6147D" @click="publish">Enregistrer</v-btn>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useClient } from '@/plugins/api.js'
const props = defineProps({
creator: {
required: true
}
})
const emits = defineEmits(['closeRequested'])
const selectedFile = ref({})
const fileUrl = ref(props.creator.images.banner)
const fallbackUrl = '/images/hutopymedia/banners/hutopyul.png'
const onFileSelected = () => {
if (selectedFile.value) {
const reader = new FileReader()
reader.onload = (event) => {
fileUrl.value = event.target.result
}
reader.readAsDataURL(selectedFile.value)
} else {
fileUrl.value = null
}
}
const client = useClient()
const publish = async () => {
try {
const formData = new FormData()
formData.append('file', selectedFile.value)
await client.post(
`/api/creators/${props.creator.id}/banner`,
formData
)
props.creator.images.banner = fileUrl
emits('closeRequested')
} catch (error) {
console.error(error)
}
}
const cancel = () => {
emits('closeRequested')
}
</script>

View File

@@ -0,0 +1,59 @@
<script setup>
import { useClient } from '@/plugins/api.js';
import { ref } from 'vue';
const props = defineProps({
creator: {
required: true,
},
});
const emits = defineEmits(['closeRequested']);
const stripeId = ref(props.creator.stripeId);
const client = useClient();
const save = async () => {
try {
await client.post(`/api/membership/stripe-account`, {
stripeAccountId: stripeId.value,
});
props.creator.stripeId = stripeId.value;
emits('closeRequested');
} catch (error) {
console.error('Error saving stripe id:', error);
}
};
const cancel = () => {
emits('closeRequested');
};
</script>
<template>
<div class="pb-5 text-2xl">Modifier le id Stripe</div>
<div class="flex flex-col space-y-4">
<v-text-field
variant="outlined"
v-model="stripeId"
label="Stripe Id"
outlined
></v-text-field>
<div class="flex justify-end space-x-4">
<v-btn color="black" variant="text" @click="cancel">Annuler</v-btn>
<v-btn color="#A6147D" @click="save">Enregistrer</v-btn>
</div>
</div>
</template>
<style scoped>
.flex {
display: flex;
}
.space-y-4 > * + * {
margin-top: 1rem;
}
</style>

View File

@@ -0,0 +1,62 @@
<script setup>
import { ref } from 'vue';
import { useClient } from '@/plugins/api.js';
const props = defineProps({
creator: {
required: true
}
});
const emits = defineEmits(['closeRequested']);
const title = ref(props.creator.title);
const client = useClient();
const save = async () => {
try {
await client.post(
`/api/creators/${props.creator.id}/title`,
{
title: title.value
}
);
props.creator.title = title.value;
emits('closeRequested');
} catch (error) {
console.error('Error saving title:', error);
}
};
const cancel = () => {
emits('closeRequested');
};
</script>
<template>
<div class="pb-5 text-2xl">Modifier le Titre</div>
<div class="flex flex-col space-y-4">
<v-text-field
variant="outlined"
v-model="title"
label="Titre"
outlined
></v-text-field>
<div class="flex justify-end space-x-4">
<v-btn color="black" variant="text" @click="cancel">Annuler</v-btn>
<v-btn color="#A6147D" @click="save">Enregistrer</v-btn>
</div>
</div>
</template>
<style scoped>
.flex {
display: flex;
}
.space-y-4 > * + * {
margin-top: 1rem;
}
</style>

Some files were not shown because too many files have changed in this diff Show More