xiongzhu пре 1 година
родитељ
комит
417164e34d

+ 1 - 0
.eslintignore

@@ -25,3 +25,4 @@ pnpm-debug.log*
 *.sln
 *.sw?
 
+/index.html

+ 0 - 1
android/.idea/misc.xml

@@ -1,4 +1,3 @@
-<?xml version="1.0" encoding="UTF-8"?>
 <project version="4">
   <component name="ExternalStorageConfigurationManager" enabled="true" />
   <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">

+ 1 - 0
android/app/capacitor.build.gradle

@@ -13,6 +13,7 @@ dependencies {
     implementation project(':capacitor-haptics')
     implementation project(':capacitor-keyboard')
     implementation project(':capacitor-status-bar')
+    implementation project(':capacitor-plugin-safe-area')
 
 }
 

+ 3 - 0
android/capacitor.settings.gradle

@@ -13,3 +13,6 @@ project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor
 
 include ':capacitor-status-bar'
 project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
+
+include ':capacitor-plugin-safe-area'
+project(':capacitor-plugin-safe-area').projectDir = new File('../node_modules/capacitor-plugin-safe-area/android')

+ 2 - 1
capacitor.config.ts

@@ -6,7 +6,8 @@ const config: CapacitorConfig = {
     webDir: "dist",
     server: {
         androidScheme: "https",
-        url: "http://192.168.6.215:4173",
+        url: "http://192.168.50.202:4173",
+        cleartext: true,
     },
     android: {},
 };

+ 58 - 11
index.html

@@ -1,16 +1,16 @@
+<!-- eslint-disable -->
 <!DOCTYPE html>
 <html lang="en">
-  <head>
+
+<head>
     <meta charset="utf-8" />
     <title>FreeShort</title>
 
     <base href="/" />
 
     <meta name="color-scheme" content="dark" />
-    <meta
-      name="viewport"
-      content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
-    />
+    <meta name="viewport"
+        content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
     <meta name="format-detection" content="telephone=no" />
     <meta name="msapplication-tap-highlight" content="no" />
 
@@ -20,12 +20,59 @@
     <meta name="apple-mobile-web-app-capable" content="yes" />
     <meta name="apple-mobile-web-app-title" content="Ionic App" />
     <meta name="apple-mobile-web-app-status-bar-style" content="black" />
-  </head>
 
-  <body class="dark">
-    <div id="app"></div>
-    <script type="module" src="/src/main.ts"></script>
-  </body>
+    <link
+        href="https://fonts.googleapis.com/css2?family=Lalezar&display=swap"
+        rel="stylesheet">
+</head>
+
+<body class="dark">
+    <div id="app">
+        <div class="absolute h-[40px] left-0 top-0 right-0 bottom-0 m-auto">
+            <svg class="m-auto" width="40" height="40" stroke="#ffffffcc" viewBox="0 0 24 24"
+                xmlns="http://www.w3.org/2000/svg">
+                <style>
+                    .spinner_V8m1 {
+                        transform-origin: center;
+                        animation: spinner_zKoa 2s linear infinite
+                    }
+
+                    .spinner_V8m1 circle {
+                        stroke-linecap: round;
+                        animation: spinner_YpZS 1.5s ease-in-out infinite
+                    }
 
-</html>
+                    @keyframes spinner_zKoa {
+                        100% {
+                            transform: rotate(360deg)
+                        }
+                    }
+
+                    @keyframes spinner_YpZS {
+                        0% {
+                            stroke-dasharray: 0 150;
+                            stroke-dashoffset: 0
+                        }
+
+                        47.5% {
+                            stroke-dasharray: 42 150;
+                            stroke-dashoffset: -16
+                        }
+
+                        95%,
+                        100% {
+                            stroke-dasharray: 42 150;
+                            stroke-dashoffset: -59
+                        }
+                    }
+                </style>
+                <g class="spinner_V8m1">
+                    <circle cx="12" cy="12" r="9.5" fill="none" stroke-width="3"></circle>
+                </g>
+            </svg>
+        </div>
+    </div>
+    <script type="module" src="/src/main.ts"></script>
+</body>
 
+</html>

+ 8 - 0
package.json

@@ -14,6 +14,7 @@
   "dependencies": {
     "@capacitor/android": "^5.7.2",
     "@capacitor/app": "5.0.7",
+    "@capacitor/assets": "^3.0.4",
     "@capacitor/core": "5.7.2",
     "@capacitor/haptics": "5.0.7",
     "@capacitor/ios": "^5.7.2",
@@ -21,20 +22,27 @@
     "@capacitor/status-bar": "5.0.7",
     "@ionic/vue": "^7.0.0",
     "@ionic/vue-router": "^7.0.0",
+    "@tailwindcss/aspect-ratio": "^0.4.2",
     "@vueuse/core": "^10.9.0",
     "@vueuse/gesture": "^2.0.0",
+    "@vueuse/integrations": "^10.9.0",
     "axios": "^1.6.7",
+    "capacitor-plugin-safe-area": "^2.0.6",
     "eruda": "^3.0.1",
     "ionicons": "^7.0.0",
     "less": "^4.2.0",
+    "pinia": "^2.1.7",
+    "qrcode": "^1",
     "qs": "^6.12.0",
     "swiper": "^11.0.7",
+    "validator": "^13.11.0",
     "vue": "^3.3.0",
     "vue-router": "^4.2.0"
   },
   "devDependencies": {
     "@capacitor/cli": "5.7.2",
     "@types/qs": "^6.9.12",
+    "@types/validator": "^13.11.9",
     "@vitejs/plugin-legacy": "^5.0.0",
     "@vitejs/plugin-vue": "^4.0.0",
     "@vue/eslint-config-typescript": "^12.0.0",

BIN
src/assets/bg_invite_cell.png


BIN
src/assets/bg_invite_modal.png


BIN
src/assets/icon_avatar.png


+ 18 - 0
src/assets/icon_avatar.svg

@@ -0,0 +1,18 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="167" height="259" viewBox="0 0 167 259" fill="none">
+<circle cx="82" cy="67" r="67"   fill="#32252C" >
+</circle>
+<g mask="url(#mask-0)">
+<circle cx="83.5" cy="57.5" r="28.5"   fill="#483B42" >
+</circle>
+</g>
+<g mask="url(#mask-0)">
+<circle cx="83.5" cy="175.5" r="83.5"   fill="#483B42" >
+</circle>
+</g>
+<defs>
+<mask id="mask-0" style="mask-type:alpha" maskUnits="userSpaceOnUse">
+<circle cx="82" cy="67" r="67"   fill="#32252C" >
+</circle>
+</mask>
+</defs>
+</svg>

+ 13 - 0
src/assets/logo.svg

@@ -0,0 +1,13 @@
+<svg xmlns="http://www.w3.org/2000/svg"
+    xmlns:xlink="http://www.w3.org/1999/xlink" width="42.00006866455078" height="29" viewBox="0 0 42.00006866455078 29" fill="none">
+    <path fill="#FFFFFF" d="M12.8224 12.2052L9.67531 24.7883L26.7631 24.7883C29.6891 24.7883 32.2336 22.8135 32.9359 20.0015L34.8865 12.2009L12.818 12.2009L12.8224 12.2052ZM29.1256 16.7345L15.4406 16.7345L15.6617 15.7342L29.3466 15.7342L29.1256 16.7345Z">
+    </path>
+    <path fill="#FFFFFF" d="M6.64959 23.8439L12.6099 0C9.45854 0 6.71461 2.12509 5.96036 5.15602L0 28.9999C3.1514 28.9999 5.89534 26.8749 6.64959 23.8439Z">
+    </path>
+    <path fill="#FFFFFF" d="M20.9805 9.76257L13.4769 9.76257L15.2846 2.57159L22.7838 2.57159L20.9805 9.76257Z">
+    </path>
+    <path fill="#FFFFFF" d="M30.4607 9.76257L22.9615 9.76257L24.7691 2.57159L32.2683 2.57159L30.4607 9.76257Z">
+    </path>
+    <path d="M36.9977 9.76257L32.4462 9.76257L34.2538 2.57159L42.0001 2.57159L40.964 6.69299C40.5089 8.4961 38.8747 9.76257 36.9977 9.76257Z" fill="#FFFFFF">
+    </path>
+</svg>

+ 271 - 192
src/components/PlayView.vue

@@ -8,102 +8,125 @@
             :poster="series?.cover"
             :controls="false"
             class="object-contain w-full h-full"
-            @timeupdate="onTimeUpdate"
-            @play="() => (userPaused = false)"
-            @pause="() => (userPaused = true)"
+            @play="onVideoDidPlay"
+            @pause="onVideoDidPause"
+            @click="exitImmersive"
+            @ended="emit('ended')"
         ></video>
         <img
             v-show="!playUrl && series"
             :src="series?.cover"
             class="object-contain w-full h-full"
         />
-
-        <div
-            v-show="!draggingData.dragging"
-            class="mask-info absolute w-full h-full top-0 left-0"
-        >
-            <IonSpinner
-                v-if="!series || !episode"
-                name="crescent"
-                class="absolute left-0 top-0 right-0 bottom-0 m-auto text-lg"
-            />
-            <IonIcon
-                v-if="showPlayBtn"
-                :icon="playCircle"
-                class="absolute left-0 top-0 right-0 bottom-0 m-auto text-6xl opacity-80"
-                @click="onPlay"
-            />
-            <IonIcon
-                v-if="showPauseBtn"
-                :icon="pauseCircle"
-                class="absolute left-0 top-0 right-0 bottom-0 m-auto text-6xl opacity-80"
-            />
-            <div class="tool-bar flex">
-                <div class="px-2 flex items-center" @click="router.back()">
+        <Transition name="fade">
+            <div
+                v-show="!hideControls"
+                class="mask-info absolute w-full h-full top-0 left-0"
+                @click="enterImmersive"
+            >
+                <IonSpinner
+                    v-if="!series || !episode"
+                    name="crescent"
+                    class="absolute left-0 top-0 right-0 bottom-0 m-auto text-lg"
+                />
+                <template v-if="!loading">
                     <IonIcon
-                        :icon="chevronBack"
-                        class="text-2xl opacity-80 h-10"
+                        v-if="showPlayBtn"
+                        :icon="playCircle"
+                        class="absolute left-0 top-0 right-0 bottom-0 m-auto text-7xl opacity-60"
+                        @click.stop="onPlay"
                     />
-                </div>
-                <div class="flex-1"></div>
-                <div class="px-4 flex items-center">
                     <IonIcon
-                        :icon="ellipsisHorizontal"
-                        class="text-2xl opacity-80 h-10"
+                        v-else
+                        :icon="pauseCircle"
+                        class="absolute left-0 top-0 right-0 bottom-0 m-auto text-7xl opacity-60"
+                        @click.stop="playing = false"
                     />
-                </div>
-            </div>
-            <div
-                class="title-info absolute bottom-0 left-0 right-0 drop-shadow px-4"
-                v-show="series"
-            >
-                <div class="pr-20">
-                    <div class="text-base line-clamp-1">
-                        {{ series?.title }}
+                </template>
+                <div class="tool-bar flex">
+                    <div class="px-2 flex items-center" @click="router.back()">
+                        <IonIcon
+                            :icon="chevronBack"
+                            class="text-2xl opacity-80 h-10"
+                        />
                     </div>
-                    <div class="text-sm text-opacity-80 text-white">
-                        Episode {{ episode?.episodeNum }}
+                    <div class="flex-1"></div>
+                    <div class="px-4 flex items-center">
+                        <IonIcon
+                            :icon="ellipsisHorizontal"
+                            class="text-2xl opacity-80 h-10"
+                        />
                     </div>
                 </div>
-                <div ref="dragTarget" class="py-3">
+                <div
+                    class="title-info absolute bottom-0 left-0 right-0 drop-shadow px-4"
+                    v-show="series"
+                >
+                    <div class="pr-20">
+                        <div class="text-base line-clamp-1">
+                            {{ series?.title }}
+                        </div>
+                        <div class="text-sm text-opacity-80 text-white">
+                            Episode {{ episode?.episodeNum }}
+                        </div>
+                    </div>
+                    <div ref="dragTarget" class="py-3">
+                        <div
+                            class="progress-bar h-[2px] bg-white bg-opacity-20 rounded"
+                            ref="progressBar"
+                        >
+                            <div
+                                v-if="loading"
+                                class="loading bg-white h-[2px] rounded bg-opacity-80"
+                            ></div>
+                            <div
+                                v-else
+                                class="progress bg-white h-[2px] rounded bg-opacity-80"
+                                :style="{ width: progress + '%' }"
+                            ></div>
+                        </div>
+                    </div>
                     <div
-                        class="progress-bar h-[2px] bg-white bg-opacity-20 rounded"
-                        ref="progressBar"
+                        class="dive-into h-[44px] flex items-center text-xs bg-opacity-20 bg-black px-4 rounded"
+                        @click.stop="showEpisodesModal = true"
                     >
-                        <div
-                            v-if="loading"
-                            class="loading bg-white h-[2px] rounded bg-opacity-80"
-                        ></div>
-                        <div
-                            v-else
-                            class="progress bg-white h-[2px] rounded bg-opacity-80"
-                            :style="{ width: progress + '%' }"
-                        ></div>
+                        <IonIcon :icon="filter" class="text-base" />
+                        <div class="flex-1 ml-2 text-white text-opacity-80">
+                            {{ series?.totalEpisodes }} Episodes
+                        </div>
+                        <IonIcon :icon="chevronForward" class="text-base" />
                     </div>
                 </div>
+
                 <div
-                    class="dive-into h-[44px] flex items-center text-xs bg-opacity-20 bg-black px-4 rounded"
-                    @click.stop="showEpisodesModal = true"
+                    class="absolute right-4 bottom-32 flex flex-col"
+                    v-if="series"
                 >
-                    <IonIcon :icon="filter" class="text-base" />
-                    <div class="flex-1 ml-2 text-white text-opacity-80">
-                        {{ series?.totalEpisodes }} Episodes
+                    <div
+                        class="btn flex flex-col items-center justify-center"
+                        @click.stop="emit('save')"
+                    >
+                        <IonIcon
+                            :class="{ 'text-prim': saved }"
+                            :icon="bookmark"
+                            class="text-4xl opacity-80"
+                        />
+                        <div class="text-xs">
+                            {{ saved ? "Saved" : "Save" }}
+                        </div>
+                    </div>
+                    <div
+                        class="btn flex flex-col items-center justify-center mt-4"
+                    >
+                        <IonIcon
+                            :icon="arrowRedo"
+                            class="text-4xl opacity-80"
+                        />
+                        <div class="text-xs">Share</div>
                     </div>
-                    <IonIcon :icon="chevronForward" class="text-base" />
-                </div>
-            </div>
-
-            <div class="absolute right-4 bottom-32 flex flex-col" v-if="series">
-                <div class="btn flex flex-col items-center justify-center">
-                    <IonIcon :icon="bookmark" class="text-4xl opacity-80" />
-                    <div class="text-xs">Save</div>
-                </div>
-                <div class="btn flex flex-col items-center justify-center mt-4">
-                    <IonIcon :icon="arrowRedo" class="text-4xl opacity-80" />
-                    <div class="text-xs">Share</div>
                 </div>
             </div>
-        </div>
+        </Transition>
         <div
             class="absolute left-4 right-4 bottom-24 shadow"
             v-if="draggingData.dragging"
@@ -114,11 +137,15 @@
                 <span
                     class="inline-block text-center w-16 text-white text-opacity-90"
                 >
-                    {{ toDuration }}
+                    {{
+                        formatDuration(
+                            (duration * draggingData.toProgress) / 100
+                        )
+                    }}
                 </span>
                 /
                 <span class="inline-block text-center w-16">
-                    {{ formatDuration(draggingData.videoDuration) }}
+                    {{ formatDuration(duration) }}
                 </span>
             </div>
             <div class="progress-bar-large h-2 bg-white bg-opacity-20 rounded">
@@ -128,111 +155,114 @@
                 ></div>
             </div>
         </div>
-    </div>
-
-    <ion-modal
-        ref="modal"
-        class="episodes-modal"
-        :is-open="showEpisodesModal"
-        :initial-breakpoint="1"
-        :breakpoints="[0, 1]"
-        @ionModalDidDismiss="showEpisodesModal = false"
-    >
-        <ion-content>
-            <div class="flex flex-col h-full">
-                <div class="text-lg mt-4 mx-4">{{ series?.title }}</div>
-                <div class="text-sm mt-1 mx-4 text-white text-opacity-80">
-                    {{ series?.totalEpisodes }} Episodes
-                </div>
-                <div class="flex-1 flex flex-wrap overflow-auto mt-4 ml-4 mr-1">
+        <ion-modal
+            ref="modal"
+            class="episodes-modal"
+            :is-open="showEpisodesModal"
+            :initial-breakpoint="1"
+            :breakpoints="[0, 1]"
+            @ionModalDidDismiss="showEpisodesModal = false"
+        >
+            <ion-content>
+                <div class="flex flex-col h-full">
+                    <div class="text-lg mt-4 mx-4">{{ series?.title }}</div>
+                    <div class="text-sm mt-1 mx-4 text-white text-opacity-80">
+                        {{ series?.totalEpisodes }} Episodes
+                    </div>
                     <div
-                        class="pr-3 w-1/4 mb-3"
-                        v-for="(item, n) in episodes || []"
-                        :key="item.id"
-                        @click="chooseEpisode(n)"
+                        class="flex-1 flex flex-wrap overflow-auto mt-4 ml-4 mr-1"
                     >
                         <div
-                            class="episode-btn h-10 bg-neutral-800 rounded flex items-center justify-center relative"
-                            :class="{ active: item.id === episode?.id }"
+                            class="pr-3 w-1/4 mb-3"
+                            v-for="(item, n) in episodes || []"
+                            :key="item.id"
+                            @click="chooseEpisode(n)"
                         >
-                            <IonIcon
-                                v-if="parseInt(item.price) > 0"
-                                :icon="lockClosed"
-                                class="absolute top-1 right-1 text-sm"
-                            />
-                            {{ item.episodeNum }}
+                            <div
+                                class="episode-btn h-10 bg-neutral-800 rounded flex items-center justify-center relative"
+                                :class="{ active: item.id === episode?.id }"
+                            >
+                                <IonIcon
+                                    v-if="parseInt(item.price) > 0"
+                                    :icon="lockClosed"
+                                    class="absolute top-1 right-1 text-sm"
+                                />
+                                {{ item.episodeNum }}
+                            </div>
                         </div>
                     </div>
                 </div>
-            </div>
-        </ion-content>
-    </ion-modal>
+            </ion-content>
+        </ion-modal>
 
-    <ion-modal
-        ref="modal"
-        class="pay-modal"
-        :is-open="showPayModal"
-        :initial-breakpoint="1"
-        :breakpoints="[0, 1]"
-        @ionModalDidDismiss="showPayModal = false"
-    >
-        <ion-content>
-            <div class="divide-y divide-neutral-600 text-white">
-                <div class="mb-6">
-                    <div
-                        class="mt-6 text-center text-sm text-prim text-opacity-80"
-                    >
-                        You haven't unlocked this episode yet
-                    </div>
-                    <div class="text-center text-xl mt-2 font-bold">
-                        Join the Membership
-                    </div>
-                    <div class="text-center text-sm mt-1 px-4">
-                        unlock and watch all our videos without any limit
-                    </div>
-                </div>
-                <div class="flex space-x-4 pt-6 px-4">
-                    <div
-                        v-for="(item, n) in plans"
-                        :key="n"
-                        class="h-44 bg-neutral-700 flex-1 rounded-lg flex flex-col items-center border-neutral-500 border-2 overflow-hidden relative"
-                        :class="{ '!border-prim': selectedPlan === n }"
-                        @click="selectedPlan = n"
-                    >
+        <ion-modal
+            ref="modal"
+            class="pay-modal"
+            :is-open="showPayModal"
+            :initial-breakpoint="1"
+            :breakpoints="[0, 1]"
+            @ionModalDidDismiss="showPayModal = false"
+        >
+            <ion-content>
+                <div class="divide-y divide-neutral-600 text-white">
+                    <div class="mb-6">
                         <div
-                            class="absolute top-0 left-0 bg-prim rounded-br-lg text-xs px-2 py-1"
-                            v-if="n === 0"
+                            class="mt-6 text-center text-sm text-prim text-opacity-80"
                         >
-                            Exclusive
+                            You haven't unlocked this episode yet
                         </div>
-                        <div class="mt-8 text-xl font-bold flex-1">
-                            {{ item.title }}
+                        <div class="text-center text-xl mt-2 font-bold">
+                            Join the Membership
                         </div>
-                        <div class="flex-1">
-                            <div
-                                class="bg-prim bg-opacity-30 rounded-full px-3 py-1 text-opacity-50 text-sm"
-                            >
-                                {{ item.desc }}
-                            </div>
+                        <div class="text-center text-sm mt-1 px-4">
+                            unlock and watch all our videos without any limit
                         </div>
+                    </div>
+                    <div class="flex space-x-4 pt-6 px-4">
                         <div
-                            class="h-8 bg-neutral-500 self-stretch text-base font-bold flex items-center justify-center"
-                            :class="{ 'bg-prim': selectedPlan === n }"
+                            v-for="(item, n) in plans"
+                            :key="n"
+                            class="h-44 bg-neutral-700 flex-1 rounded-lg flex flex-col items-center border-neutral-500 border-2 overflow-hidden relative"
+                            :class="{ '!border-prim': selectedPlan === n }"
+                            @click="selectedPlan = n"
                         >
-                            <ion-label color=""> ${{ item.price }}</ion-label>
+                            <div
+                                class="absolute top-0 left-0 bg-prim rounded-br-lg text-xs px-2 py-1"
+                                v-if="n === 0"
+                            >
+                                Exclusive
+                            </div>
+                            <div class="mt-8 text-xl font-bold flex-1">
+                                {{ item.title }}
+                            </div>
+                            <div class="flex-1">
+                                <div
+                                    class="bg-prim bg-opacity-30 rounded-full px-3 py-1 text-opacity-50 text-sm"
+                                >
+                                    {{ item.desc }}
+                                </div>
+                            </div>
+                            <div
+                                class="h-8 bg-neutral-500 self-stretch text-base font-bold flex items-center justify-center"
+                                :class="{ 'bg-prim': selectedPlan === n }"
+                            >
+                                <ion-label color="">
+                                    ${{ item.price }}
+                                </ion-label>
+                            </div>
                         </div>
                     </div>
                 </div>
-            </div>
-            <IonButton
-                color="tertiary"
-                expand="block"
-                class="mx-4 mt-8 font-bold"
-            >
-                ${{ plans[selectedPlan].price }} Pay Now
-            </IonButton>
-        </ion-content>
-    </ion-modal>
+                <IonButton
+                    color="tertiary"
+                    expand="block"
+                    class="mx-4 mt-8 font-bold"
+                >
+                    ${{ plans[selectedPlan].price }} Pay Now
+                </IonButton>
+            </ion-content>
+        </ion-modal>
+    </div>
 </template>
 <script setup lang="ts">
 import { Ref, computed, nextTick, onMounted, reactive, ref, watch } from "vue";
@@ -259,20 +289,24 @@ import {
     lockClosed,
 } from "ionicons/icons";
 import http from "@/plugins/http";
+import { watchThrottled } from "@vueuse/core";
 
 const props = defineProps({
     active: Boolean,
     series: Object,
     episode: Object,
     episodes: Array<any>,
+    saved: Boolean,
 });
-const emit = defineEmits(["chooseEpisode"]);
+const emit = defineEmits(["chooseEpisode", "save", "ended"]);
 const router = useIonRouter();
 const video: Ref<HTMLVideoElement | null> = ref(null);
 const { playing, currentTime, duration, volume } = useMediaControls(video);
-const userPaused = ref(false);
 const showPauseBtn = ref(false);
-const progress = ref(0);
+const progress = computed(() => {
+    if (isNaN(duration.value) || duration.value === 0) return 0;
+    return (currentTime.value / duration.value) * 100;
+});
 const loading = ref(false);
 const dragTarget = ref();
 const progressBar: Ref<HTMLElement | null> = ref(null);
@@ -305,8 +339,21 @@ const plans = [
     },
 ];
 const selectedPlan = ref(0);
+const immersive = ref(false);
+
+const showPlayBtn = computed(() => {
+    if (!props.series || loading.value) return false;
+    if (!playUrl.value) return true;
+    if (!playing.value) return true;
+    return false;
+});
+
+const hideControls = computed(() => {
+    return draggingData.dragging || (playing.value && immersive.value);
+});
 
 function formatDuration(duration: number) {
+    if (isNaN(duration)) return "00:00";
     const minutes = Math.floor(duration / 60)
         .toString()
         .padStart(2, "0");
@@ -315,15 +362,11 @@ function formatDuration(duration: number) {
         .padStart(2, "0");
     return `${minutes}:${seconds}`;
 }
-const toDuration = computed(() => {
-    return formatDuration(
-        Math.floor((draggingData.toProgress * draggingData.videoDuration) / 100)
-    );
-});
+
 const dragHandler = ({ movement: [x, y], dragging }: any) => {
     if (dragging && !draggingData.dragging) {
         draggingData.barWidth = useElementBounding(progressBar).width.value;
-        draggingData.videoDuration = 100;
+        draggingData.videoDuration = duration.value;
         draggingData.progress = progress.value;
         draggingData.toProgress = progress.value;
         draggingData.dragging = true;
@@ -338,16 +381,11 @@ const dragHandler = ({ movement: [x, y], dragging }: any) => {
     );
     if (!dragging) {
         draggingData.dragging = false;
+        currentTime.value = (duration.value * draggingData.toProgress) / 100;
     }
+    console.log(JSON.stringify(draggingData));
 };
 
-function onTimeUpdate() {
-    if (video.value) {
-        progress.value =
-            (video.value!.currentTime! / video.value!.duration!) * 100;
-    }
-}
-
 function getEpisode() {
     if (!props.episode) return;
     loading.value = true;
@@ -369,12 +407,14 @@ function getEpisode() {
     }
     loading.value = false;
 }
-
+useDrag(dragHandler, {
+    domTarget: dragTarget,
+    eventOptions: {
+        capture: true,
+    },
+});
 onMounted(() => {
     getEpisode();
-    useDrag(dragHandler, {
-        domTarget: dragTarget,
-    });
 });
 watch(
     () => props.episode,
@@ -393,16 +433,7 @@ watch(
     }
 );
 
-const showPlayBtn = computed(() => {
-    if (!props.series || loading.value) return false;
-    if (!playUrl.value) return true;
-    if (userPaused.value) return true;
-    if (!playing.value) return true;
-    return false;
-});
-
 function onPlay() {
-    console.log("onPlay", playUrl.value);
     if (playUrl.value) {
         playing.value = true;
     } else if (needPay.value) {
@@ -416,6 +447,45 @@ function chooseEpisode(n: number) {
         emit("chooseEpisode", n);
     }, 100);
 }
+
+const videoPlayTimeout = ref<any>(0);
+const immersiveTimeout = ref<any>(0);
+
+function enterImmersive() {
+    if (playing.value) {
+        console.log("enterImmersive");
+        immersive.value = true;
+    }
+}
+
+function exitImmersive() {
+    console.log("exitImmersive");
+    immersive.value = false;
+    clearTimeout(immersiveTimeout.value);
+    immersiveTimeout.value = setTimeout(enterImmersive, 3000);
+}
+
+function onVideoDidPlay() {
+    videoPlayTimeout.value = setTimeout(enterImmersive, 3000);
+}
+
+function onVideoDidPause() {
+    exitImmersive();
+}
+
+function onVideoEnd() {}
+
+watchThrottled(
+    currentTime,
+    () => {
+        http.post("/playHistories", {
+            seriesId: props.series?.id,
+            episodeId: props.episode?.id,
+            duration: currentTime.value,
+        });
+    },
+    { throttle: 3000 }
+);
 </script>
 <style lang="less" scoped>
 .mask-info {
@@ -470,4 +540,13 @@ ion-modal {
 .pay-modal {
     --height: 460px;
 }
+.fade-enter-active,
+.fade-leave-active {
+    transition: opacity 0.3s;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+    opacity: 0;
+}
 </style>

+ 16 - 12
src/components/SeriesItem.vue

@@ -11,9 +11,9 @@
                     width: `${itemSize.width}px`,
                     height: `${itemSize.height}px`,
                 }"
-                v-if="item"
+                v-if="series"
                 class="rounded object-cover"
-                :src="item.cover"
+                :src="series.cover"
             />
             <ion-skeleton-text
                 v-else
@@ -22,11 +22,11 @@
             ></ion-skeleton-text>
         </IonThumbnail>
         <div
-            v-if="item"
-            class="text-sm line-clamp-2 mt-1"
+            v-if="series"
+            class="text-xs line-clamp-2 mt-1"
             :style="{ width: `${itemSize.width}px` }"
         >
-            {{ item.title }}
+            {{ series.title }}
         </div>
     </div>
 </template>
@@ -36,7 +36,9 @@ import { useWindowSize } from "@vueuse/core";
 import { computed } from "vue";
 
 const props = defineProps({
-    item: Object,
+    series: Object,
+    episode: Object,
+    size: Object,
 });
 const { width, height } = useWindowSize();
 
@@ -49,18 +51,20 @@ const itemSize = computed(() => {
         itemHeight = Math.floor((height.value - 60) / 2);
         itemWidth = Math.floor((itemHeight * 9) / 16);
     }
-    return {
-        width: itemWidth,
-        height: itemHeight,
-    };
+    return (
+        props.size || {
+            width: itemWidth,
+            height: itemHeight,
+        }
+    );
 });
 
 const router = useIonRouter();
 const detail = () => {
     console.log("detail");
-    if (!props.item) return;
+    if (!props.series) return;
     router.push({
-        path: `/series/${props.item.id}`,
+        path: `/series/${props.series.id}`,
     });
 };
 </script>

+ 27 - 7
src/main.ts

@@ -1,9 +1,13 @@
+import eruda from "eruda";
 import { createApp } from "vue";
+import { createPinia } from "pinia";
 import App from "./App.vue";
 import router from "./router";
-
 import { IonicVue } from "@ionic/vue";
-import eruda from "eruda";
+import { GesturePlugin } from "@vueuse/gesture";
+import { Capacitor } from "@capacitor/core";
+import { StatusBar, Style } from "@capacitor/status-bar";
+import { SafeArea } from "capacitor-plugin-safe-area";
 
 import "./theme/tailwind.css";
 
@@ -23,21 +27,37 @@ import "@ionic/vue/css/text-transformation.css";
 import "@ionic/vue/css/flex-utils.css";
 import "@ionic/vue/css/display.css";
 
+import "swiper/css";
+
 /* Theme variables */
 import "./theme/variables.css";
 
-import "swiper/css";
+import "./theme/main.less";
+
+if (process.env.NODE_ENV === "development") {
+    eruda.init();
+}
+
+if (Capacitor.isNativePlatform()) {
+    StatusBar.setOverlaysWebView({ overlay: true });
+    if (Capacitor.getPlatform() === "android") {
+        SafeArea.getSafeAreaInsets().then(({ insets }) => {
+            const style = document.documentElement.style;
+            style.setProperty("--ion-safe-area-top", insets.top + "px");
+            // style.setProperty('--ion-safe-area-bottom', insets.bottom + 'px')
+        });
+    }
+}
 
 const app = createApp(App)
+    .use(createPinia())
     .use(IonicVue, {
         mode: "ios",
+        swipeBackEnabled: false,
     })
+    .use(GesturePlugin)
     .use(router);
 
 router.isReady().then(() => {
     app.mount("#app");
 });
-
-if (process.env.NODE_ENV === "development") {
-    // eruda.init();
-}

+ 34 - 3
src/plugins/http.ts

@@ -51,7 +51,7 @@ const http = {
         }
         return new URL(path, base).href;
     },
-    setToken(_token: string) {
+    setToken(_token?: string | null) {
         token.value = _token;
         if (_token) {
             axiosInstance.defaults.headers.common["Authorization"] =
@@ -60,7 +60,7 @@ const http = {
             axiosInstance.defaults.headers.common["Authorization"] = null;
         }
     },
-    async login(phone: string, password: string) {
+    async login(phone: string, password: string): Promise<any> {
         const { data: token } = await axiosInstance.post(
             "/auth/login",
             qs.stringify({ phone, password })
@@ -79,7 +79,19 @@ const http = {
                 });
         });
     },
-    post(url: string, body: any, options: any) {
+    delete(url: string, params?: any): Promise<any> {
+        return new Promise((resolve, reject) => {
+            axiosInstance
+                .delete(url, { params, withCredentials: true })
+                .then((res) => {
+                    resolve(res.data);
+                })
+                .catch((e) => {
+                    reject(e);
+                });
+        });
+    },
+    post(url: string, body: any, options?: any): Promise<any> {
         options = options || {};
         body = body || {};
         if (!(body instanceof FormData)) {
@@ -98,6 +110,25 @@ const http = {
                 });
         });
     },
+    put(url: string, body: any, options?: any): Promise<any> {
+        options = options || {};
+        body = body || {};
+        if (!(body instanceof FormData)) {
+            if (options.body !== "json") {
+                body = qs.stringify(body);
+            }
+        }
+        return new Promise((resolve, reject) => {
+            axiosInstance
+                .put(url, body, { withCredentials: true })
+                .then((res) => {
+                    resolve(res.data);
+                })
+                .catch((e) => {
+                    reject(e);
+                });
+        });
+    },
 };
 
 export default http;

+ 23 - 0
src/plugins/toast.ts

@@ -0,0 +1,23 @@
+import { toastController } from "@ionic/vue";
+
+async function toast(
+    msg: string,
+    options?: {
+        position?: "top" | "middle" | "bottom";
+        type?: "success" | "error" | "info" | "";
+    }
+) {
+    options = options || {};
+    options.position = options.position || "bottom";
+    options.type = options.type || "";
+    const toast = await toastController.create({
+        message: msg,
+        duration: 2000,
+        position: options.position,
+        cssClass: options.type,
+    });
+
+    await toast.present();
+}
+
+export default toast;

+ 34 - 0
src/router/index.ts

@@ -1,6 +1,8 @@
 import { createRouter, createWebHistory } from "@ionic/vue-router";
 import { RouteRecordRaw } from "vue-router";
 import TabsPage from "../views/TabsPage.vue";
+import { useUserStore } from "@/store/user";
+import http from "@/plugins/http";
 
 const routes: Array<RouteRecordRaw> = [
     {
@@ -37,6 +39,18 @@ const routes: Array<RouteRecordRaw> = [
         path: "/series/:id",
         component: () => import("@/views/SeriesView.vue"),
     },
+    {
+        path: "/login",
+        component: () => import("@/views/LoginView.vue"),
+    },
+    {
+        path: "/settings",
+        component: () => import("@/views/SettingsView.vue"),
+    },
+    {
+        path: "/profile",
+        component: () => import("@/views/ProfileView.vue"),
+    },
 ];
 
 const router = createRouter({
@@ -44,4 +58,24 @@ const router = createRouter({
     routes,
 });
 
+router.beforeEach(async (to, from, next) => {
+    console.log("route change", to);
+    // await new Promise((resolve) => setTimeout(resolve, 3000));
+    const { user, setUser } = useUserStore();
+    if (!user && to.name !== "login") {
+        try {
+            const res = await http.get("/users/my");
+            setUser(res);
+            next();
+        } catch (err) {
+            const res = await http.get("/users/guest");
+            setUser(res.user);
+            http.setToken(res.token);
+            next();
+        }
+    } else {
+        next();
+    }
+});
+
 export default router;

+ 26 - 0
src/store/user.ts

@@ -0,0 +1,26 @@
+import { ref, computed } from "vue";
+import { defineStore } from "pinia";
+import http from "@/plugins/http";
+
+declare interface User {
+    id: number;
+    username: string;
+    visitor: boolean;
+    avatar?: string;
+    email?: string;
+}
+
+export const useUserStore = defineStore("user", () => {
+    const user = ref<User | null>(null);
+    function setUser(newUser: User | null) {
+        user.value = newUser;
+    }
+
+    function getUser() {
+        http.get("/users/my").then((res) => {
+            setUser(res);
+        });
+    }
+
+    return { user, setUser, getUser };
+});

+ 10 - 0
src/theme/main.less

@@ -0,0 +1,10 @@
+ion-toast {
+    &.success {
+        --background: var(--ion-color-success);
+        color: var(--ion-color-success-contrast);
+    }
+    &.error {
+        --background: var(--ion-color-danger);
+        color: var(--ion-color-danger-contrast);
+    }
+}

+ 2 - 0
src/theme/variables.css

@@ -95,6 +95,8 @@ http://ionicframework.com/docs/theming/ */
     --ion-item-background: #000000;
 
     --ion-card-background: #1c1c1d;
+
+    --ion-safe-area-top: env(safe-area-inset-top);
 }
 
 ion-modal {

+ 47 - 23
src/views/HomeTab.vue

@@ -1,28 +1,47 @@
 <template>
     <ion-page>
         <ion-content :fullscreen="true">
-            <VueSwiper
-                :initialSlide="0"
-                :slides-per-view="1"
-                :space-between="0"
-                :loop="true"
-                :pagination="true"
-                :autoplay="{
-                    delay: 2500,
-                }"
-                @swiper="onSwiper"
-                :modules="[Navigation, Pagination, Scrollbar, A11y]"
-            >
-                <swiper-slide
-                    class="swiper-slide"
-                    v-for="item in banners"
-                    :key="item.id"
+            <div class="relative">
+                <VueSwiper
+                    :initialSlide="0"
+                    :slides-per-view="1"
+                    :space-between="0"
+                    :loop="true"
+                    :pagination="true"
+                    :autoplay="{
+                        delay: 2500,
+                    }"
+                    @swiper="onSwiper"
+                    :modules="[Navigation, Pagination, Scrollbar, A11y]"
                 >
-                    <img :src="item.img" />
-                    <div class="mask"></div>
-                    <ion-button class="btn-play">Play Now</ion-button>
-                </swiper-slide>
-            </VueSwiper>
+                    <swiper-slide
+                        class="swiper-slide"
+                        v-for="item in banners"
+                        :key="item.id"
+                    >
+                        <img :src="item.img" />
+                        <div class="mask"></div>
+                        <ion-button class="btn-play" @click="detail(item)">
+                            Play Now
+                        </ion-button>
+                    </swiper-slide>
+                </VueSwiper>
+                <div
+                    class="absolute top-4 left-4 leading-1 text-xl z-10"
+                    style="
+                        font-family: Lalezar;
+                        margin-top: var(--ion-safe-area-top);
+                    "
+                >
+                    <div class="inline-block">
+                        <img
+                            class="h-[20px] relative top-[3px] left-[4px]"
+                            src="@/assets/logo.svg"
+                        />
+                    </div>
+                    reeShort
+                </div>
+            </div>
             <div class="py-6">
                 <div
                     class="mt-12 first:mt-0"
@@ -30,7 +49,7 @@
                     :key="category.id"
                 >
                     <div class="px-4">
-                        <div v-if="category.id" class="m-0 text-xl">
+                        <div v-if="category.id" class="m-0 text-xl font-bold">
                             {{ category.name }}
                         </div>
                         <ion-skeleton-text
@@ -60,7 +79,7 @@
                             class="mr-2"
                             v-for="item in category.series"
                             :key="item.id"
-                            :item="item"
+                            :series="item"
                         >
                         </SeriesItem>
                     </div>
@@ -88,6 +107,7 @@ import { Navigation, Pagination, Scrollbar, A11y } from "swiper/modules";
 import http from "@/plugins/http";
 import { Ref, nextTick, onMounted, ref } from "vue";
 import SeriesItem from "@/components/SeriesItem.vue";
+import router from "@/router";
 
 const swiper = ref<Swiper | null>(null);
 const onSwiper = (sw: Swiper) => {
@@ -115,6 +135,10 @@ onMounted(() => {
         });
     });
 });
+function detail(item: any) {
+    console.log(item);
+    router.push({ path: item.link });
+}
 </script>
 <style lang="less" scoped>
 .swiper-slide {

+ 258 - 0
src/views/LoginView.vue

@@ -0,0 +1,258 @@
+<template>
+    <IonPage>
+        <IonHeader>
+            <IonToolbar>
+                <ion-buttons slot="start">
+                    <ion-back-button text=""></ion-back-button>
+                </ion-buttons>
+            </IonToolbar>
+        </IonHeader>
+        <IonContent fullscreen>
+            <div class="p-12">
+                <div class="grid place-content-center">
+                    <div
+                        class="leading-1 text-3xl"
+                        style="font-family: Lalezar"
+                    >
+                        <div class="inline-block">
+                            <img
+                                class="h-[30px] relative top-1 left-1"
+                                src="@/assets/logo.svg"
+                            />
+                        </div>
+                        reeShort
+                    </div>
+                </div>
+                <div class="mt-12 relative">
+                    <Transition name="fade">
+                        <div
+                            v-if="mode === 0"
+                            class="absolute top-0 left-0 w-full"
+                        >
+                            <div class="text-xl font-bold">Sign in</div>
+                            <div class="text-base">
+                                <input
+                                    type="text"
+                                    inputmode="email"
+                                    class="w-full h-12 mt-6 rounded-md px-4 bg-neutral-800 active:border-2 focus:border-2 border-prim focus:border-prim outline-none"
+                                    placeholder="Email Address"
+                                    v-model="loginForm.email"
+                                />
+
+                                <input
+                                    type="password"
+                                    class="w-full h-12 mt-6 rounded-md px-4 bg-neutral-800 active:border-2 focus:border-2 border-prim focus:border-prim outline-none"
+                                    placeholder="Password (6-32 Characters)"
+                                    v-model="loginForm.password"
+                                />
+
+                                <div class="mt-6 font-bold">
+                                    <button
+                                        class="w-full h-12 bg-prim text-white rounded-md disabled:bg-opacity-50 disabled:text-opacity-50"
+                                        :disabled="
+                                            loginDisabled || loginForm.loading
+                                        "
+                                        @click="login"
+                                    >
+                                        Sign in
+                                    </button>
+
+                                    <button
+                                        class="w-full h-12 mt-6 bg-white text-black rounded-md"
+                                        @click="mode = 1"
+                                    >
+                                        Create an account
+                                    </button>
+                                </div>
+                            </div>
+                        </div>
+                        <div v-else class="absolute top-0 left-0 w-full">
+                            <div class="text-xl font-bold">
+                                Create an account
+                            </div>
+                            <div class="text-base">
+                                <input
+                                    type="text"
+                                    inputmode="email"
+                                    class="w-full h-12 mt-6 rounded-md px-4 bg-neutral-800 active:border-2 focus:border-2 border-prim focus:border-prim outline-none"
+                                    placeholder="Email Address"
+                                    v-model="registerForm.email"
+                                />
+
+                                <input
+                                    type="password"
+                                    class="w-full h-12 mt-6 rounded-md px-4 bg-neutral-800 active:border-2 focus:border-2 border-prim focus:border-prim outline-none"
+                                    placeholder="Password (6-32 Characters)"
+                                    v-model="registerForm.password"
+                                />
+
+                                <input
+                                    type="password"
+                                    class="w-full h-12 mt-6 rounded-md px-4 bg-neutral-800 active:border-2 focus:border-2 border-prim focus:border-prim outline-none"
+                                    placeholder="Confirm Password"
+                                    v-model="registerForm.confirmPassword"
+                                />
+
+                                <div class="mt-6 font-bold">
+                                    <button
+                                        class="w-full h-12 bg-prim text-white rounded-md disabled:bg-opacity-50 disabled:text-opacity-50"
+                                        :disabled="registerDisabled"
+                                        @click="register"
+                                    >
+                                        Sign up
+                                    </button>
+
+                                    <button
+                                        class="w-full h-12 mt-6 bg-white text-black rounded-md"
+                                        @click="mode = 0"
+                                    >
+                                        Sign in
+                                    </button>
+                                </div>
+                            </div>
+                        </div>
+                    </Transition>
+                    <Transition name="fade"> </Transition>
+                </div>
+            </div>
+        </IonContent>
+    </IonPage>
+</template>
+<script setup lang="ts">
+import { computed, reactive, ref } from "vue";
+import {
+    IonPage,
+    IonContent,
+    IonHeader,
+    IonToolbar,
+    IonTitle,
+    IonButtons,
+    IonBackButton,
+    IonButton,
+    toastController,
+    useIonRouter,
+} from "@ionic/vue";
+import isEmail from "validator/lib/isEmail";
+import http from "@/plugins/http";
+import { useUserStore } from "@/store/user";
+import { storeToRefs } from "pinia";
+import toast from "@/plugins/toast";
+
+const router = useIonRouter();
+const { setUser } = useUserStore();
+const mode = ref(0);
+const loginForm = reactive({
+    email: "",
+    password: "",
+    loading: false,
+});
+const registerForm = reactive({
+    email: "",
+    password: "",
+    confirmPassword: "",
+    loading: false,
+});
+const loginDisabled = computed(() => {
+    return !loginForm.email || !loginForm.password;
+});
+const registerDisabled = computed(() => {
+    return (
+        !registerForm.email ||
+        !registerForm.password ||
+        !registerForm.confirmPassword
+    );
+});
+
+async function login() {
+    if (!loginForm.email) {
+        toast("Email is required", { type: "error" });
+        return;
+    }
+    try {
+        loginForm.loading = true;
+        const { token } = await http.post("/auth/login", {
+            username: loginForm.email,
+            password: loginForm.password,
+        });
+        http.setToken(token);
+        const user = await http.get("/users/my");
+        setUser(user);
+        if (router.canGoBack()) {
+            router.back();
+        } else {
+            router.replace("/");
+        }
+    } catch (error: any) {
+        presentToast(error.errors[0]?.message, { type: "error" });
+    }
+    loginForm.loading = false;
+}
+async function register() {
+    if (!registerForm.email) {
+        presentToast("Email is required", { type: "error" });
+        return;
+    }
+    if (!isEmail(registerForm.email)) {
+        presentToast("Invalid email", { type: "error" });
+        return;
+    }
+    if (!registerForm.password) {
+        presentToast("Password is required", { type: "error" });
+        return;
+    }
+    if (registerForm.password.length < 6) {
+        presentToast("Password must be at least 6 characters", {
+            type: "error",
+        });
+        return;
+    }
+    if (registerForm.password !== registerForm.confirmPassword) {
+        presentToast("Passwords do not match", { type: "error" });
+        return;
+    }
+    try {
+        registerForm.loading = true;
+        const { token } = await http.post("/auth/register", {
+            email: registerForm.email,
+            password: registerForm.password,
+        });
+        http.setToken(token);
+        const user = await http.get("/users/my");
+        setUser(user);
+        if (router.canGoBack()) {
+            router.back();
+        } else {
+            router.replace("/");
+        }
+    } catch (error: any) {
+        presentToast(error.errors[0]?.message, { type: "error" });
+    }
+    registerForm.loading = false;
+}
+</script>
+<style lang="less" scoped>
+ion-toolbar {
+    --background: transparent;
+    --border-width: 0;
+    --border-style: none;
+}
+.fade-enter-active,
+.fade-leave-active {
+    transition:
+        opacity 0.3s ease-in-out,
+        transform 0.3s ease-in-out;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+    opacity: 0;
+}
+
+.fade-enter-from {
+    transform: translateX(100%);
+}
+
+.fade-leave-to {
+    transform: translateX(-100%);
+}
+</style>

+ 199 - 18
src/views/MeTab.vue

@@ -1,23 +1,204 @@
 <template>
-  <ion-page>
-    <ion-header>
-      <ion-toolbar>
-        <ion-title>Tab 3</ion-title>
-      </ion-toolbar>
-    </ion-header>
-    <ion-content :fullscreen="true">
-      <ion-header collapse="condense">
-        <ion-toolbar>
-          <ion-title size="large">Tab 3</ion-title>
-        </ion-toolbar>
-      </ion-header>
-
-      <ExploreContainer name="Tab 3 page" />
-    </ion-content>
-  </ion-page>
+    <ion-page>
+        <ion-content :fullscreen="true">
+            <div>
+                <div class="top mt-4 px-4">
+                    <div class="flex items-center">
+                        <div class="text-xl font-bold flex-1">Me</div>
+                        <div class="flex p-2">
+                            <IonIcon
+                                :icon="settingsOutline"
+                                class="text-2xl -mr-2"
+                                @click="router.push('/settings')"
+                            />
+                        </div>
+                    </div>
+                    <div class="mt-4 flex items-center">
+                        <img
+                            :src="user?.avatar || IconAvatar"
+                            class="w-[64px] h-[64px] rounded-full object-cover"
+                        />
+                        <div class="ml-4 flex-1">
+                            <div class="text-lg">{{ user?.username }}</div>
+                            <div
+                                v-if="!user || user.visitor"
+                                class="mt-1 text-sm text-neutral-500"
+                            >
+                                VISITOR
+                            </div>
+                        </div>
+                        <div
+                            v-if="!user || user.visitor"
+                            class="py-1 px-2 text-sm border border-neutral-500 rounded-md"
+                            @click="router.push('/login')"
+                        >
+                            Sign in
+                        </div>
+                    </div>
+                </div>
+                <div class="px-4">
+                    <div
+                        class="h-[64px] bg-cover text-sm rounded-xl mt-8 flex items-center px-4"
+                        :style="{ backgroundImage: `url(${bgInviteCell})` }"
+                    >
+                        <div class="flex-1 leading-none">
+                            <div
+                                class="leading-none font-bold text-white text-opacity-90"
+                            >
+                                SHARE WITH YOUR FRIENDS
+                            </div>
+                            <div
+                                class="mt-1 leading-none font-bold text-white text-opacity-90"
+                            >
+                                GET FREE WATCHING
+                            </div>
+                        </div>
+                        <div
+                            class="bg-white text-prim font-bold px-3 py-1 rounded-full shadow"
+                            @click="showInviteModal = true"
+                        >
+                            SHARE
+                        </div>
+                    </div>
+                    <div class="mt-8 flex items-center text-xl h-16">
+                        <IonIcon class="text-neutral-100" :icon="timeOutline" />
+                        <div class="ml-2 text-base flex-1">Watch History</div>
+                        <IonIcon
+                            class="text-neutral-500"
+                            :icon="chevronForwardOutline"
+                        ></IonIcon>
+                    </div>
+                    <div class="flex overflow-auto -mx-4 pl-4">
+                        <SeriesItem
+                            v-for="item in histories"
+                            :key="item.id"
+                            :series="item.series"
+                            :episode="item.episode"
+                            class="mr-4"
+                        />
+                    </div>
+                    <div class="flex items-center text-xl h-16">
+                        <IonIcon
+                            class="text-neutral-100"
+                            :icon="chatboxOutline"
+                        />
+                        <div class="ml-2 text-base flex-1">Feedback</div>
+                        <IonIcon
+                            class="text-neutral-500"
+                            :icon="chevronForwardOutline"
+                        ></IonIcon>
+                    </div>
+                </div>
+            </div>
+            <ion-modal
+                id="invite-modal"
+                :isOpen="showInviteModal"
+                @didDismiss="showInviteModal = false"
+            >
+                <div class="content-wrapper text-base font-bold">
+                    <div
+                        class="code-wrapper flex flex-col items-center justify-center"
+                    >
+                        <div class="font-bold">INVITE 3 FRIENDS</div>
+                        <div class="">GET 7 DAYS FREE WATCHING</div>
+                        <img class="mt-2" :src="qrcode" alt="QR Code" />
+                        <div class="mt-2 font-bold">INVITED: 0</div>
+                    </div>
+                    <div class="flex mt-4 text-center text-sm">
+                        <div
+                            class="flex-grow border-2 border-white rounded-full box-border h-10 flex items-center justify-center"
+                        >
+                            Save QR Code
+                        </div>
+                        <div
+                            class="ml-6 flex-grow bg-prim rounded-full h-10 flex items-center justify-center"
+                        >
+                            Share
+                        </div>
+                    </div>
+                </div>
+            </ion-modal>
+        </ion-content>
+    </ion-page>
 </template>
 
 <script setup lang="ts">
-import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent } from '@ionic/vue';
-import ExploreContainer from '@/components/ExploreContainer.vue';
+import { ref } from "vue";
+import {
+    IonPage,
+    IonContent,
+    IonIcon,
+    onIonViewDidEnter,
+    IonModal,
+    useIonRouter,
+    IonItemGroup,
+    IonItem,
+    IonLabel,
+} from "@ionic/vue";
+import {
+    settingsOutline,
+    timeOutline,
+    chevronForwardOutline,
+    chatboxOutline,
+} from "ionicons/icons";
+import { storeToRefs } from "pinia";
+import { useUserStore } from "@/store/user";
+import http from "@/plugins/http";
+import bgInviteCell from "@/assets/bg_invite_cell.png";
+import bgInviteModal from "@/assets/bg_invite_modal.png";
+import { useQRCode } from "@vueuse/integrations/useQRCode";
+import SeriesItem from "@/components/SeriesItem.vue";
+import IconAvatar from "@/assets/icon_avatar.png";
+
+const router = useIonRouter();
+const { user } = storeToRefs(useUserStore());
+const histories = ref<any[]>([]);
+function getHistories() {
+    http.get("/playHistories").then((res) => {
+        histories.value = res.data;
+    });
+}
+onIonViewDidEnter(() => {
+    getHistories();
+});
+
+const showInviteModal = ref(false);
+const qrcode = useQRCode("text-to-encode", {
+    margin: 2,
+    width: 500,
+});
 </script>
+<style lang="less" scoped>
+.top {
+    padding-top: var(--ion-safe-area-top);
+    background: linear-gradient(
+        180deg,
+        rgba(22, 3, 9, 1) 0%,
+        rgba(51, 5, 14, 0) 100%
+    );
+}
+#invite-modal {
+    --width: fit-content;
+    --min-width: 290px;
+    --height: fit-content;
+    --border-radius: 0;
+    --background: transparent;
+    .content-wrapper {
+        .code-wrapper {
+            width: 290px;
+            height: 350px;
+            background-image: url(@/assets/bg_invite_modal.png);
+            background-size: cover;
+            background-position: center;
+            img {
+                width: 215px;
+                height: 215px;
+                border-radius: 8px;
+            }
+        }
+    }
+}
+ion-item {
+    --min-height: 54px;
+}
+</style>

+ 94 - 17
src/views/MyListTab.vue

@@ -1,23 +1,100 @@
 <template>
-  <ion-page>
-    <ion-header>
-      <ion-toolbar>
-        <ion-title>Tab 3</ion-title>
-      </ion-toolbar>
-    </ion-header>
-    <ion-content :fullscreen="true">
-      <ion-header collapse="condense">
-        <ion-toolbar>
-          <ion-title size="large">Tab 3</ion-title>
-        </ion-toolbar>
-      </ion-header>
+    <ion-page>
+        <ion-header>
+            <ion-toolbar>
+                <ion-title>My List</ion-title>
+            </ion-toolbar>
+        </ion-header>
+        <ion-content :fullscreen="true">
+            <ion-header collapse="condense">
+                <ion-toolbar>
+                    <ion-title size="large">My List</ion-title>
+                </ion-toolbar>
+            </ion-header>
+            <div class="p-4">
+                <div
+                    class="grid grid-cols-2 xs:grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8 gap-4"
+                    ref="container"
+                >
+                    <div
+                        v-for="item in collections"
+                        :key="item.id"
+                        @click="detail(item)"
+                    >
+                        <div class="aspect-w-9 aspect-h-16">
+                            <img
+                                class="object-cover rounded md:rounded-md xl:rounded-lg"
+                                :src="item.series.cover"
+                            />
+                        </div>
 
-      <ExploreContainer name="Tab 3 page" />
-    </ion-content>
-  </ion-page>
+                        <div class="text-xs line-clamp-2 mt-1">
+                            {{ item.series.title }}
+                        </div>
+                    </div>
+                    <div
+                        class=""
+                        @click="router.replace({ path: '/tabs/home' })"
+                    >
+                        <div
+                            class="aspect-w-9 aspect-h-16 rounded bg-neutral-800"
+                        >
+                            <div class="grid place-content-center">
+                                <div class="text-neutral-400 leading-none">
+                                    <ion-icon class="text-3xl" :icon="add" />
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </ion-content>
+    </ion-page>
 </template>
 
 <script setup lang="ts">
-import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent } from '@ionic/vue';
-import ExploreContainer from '@/components/ExploreContainer.vue';
+import http from "@/plugins/http";
+import {
+    IonPage,
+    IonHeader,
+    IonToolbar,
+    IonTitle,
+    IonContent,
+    onIonViewDidEnter,
+    IonIcon,
+    useIonRouter,
+} from "@ionic/vue";
+import { useWindowSize, useElementSize } from "@vueuse/core";
+import { computed, onActivated, onMounted, ref } from "vue";
+import SeriesItem from "@/components/SeriesItem.vue";
+import { add } from "ionicons/icons";
+
+const router = useIonRouter();
+const collections = ref<any[]>([]);
+function getData() {
+    http.get("/collections").then((res) => {
+        collections.value = [...res.data];
+    });
+}
+onIonViewDidEnter(() => {
+    getData();
+});
+const { width, height } = useWindowSize();
+const container = ref(null);
+const { width: containerWidth, height: containerHeight } =
+    useElementSize(container);
+const itemSize = computed(() => {
+    const itemWidth =
+        Math.floor(
+            containerWidth.value / Math.floor(containerWidth.value / 108)
+        ) - 16;
+    const itemHeight = (itemWidth * 16) / 9;
+    return {
+        width: itemWidth,
+        height: itemHeight,
+    };
+});
+function detail(item: any) {
+    router.push({ path: `/series/${item.series.id}` });
+}
 </script>

+ 111 - 0
src/views/ProfileView.vue

@@ -0,0 +1,111 @@
+<template>
+    <IonPage>
+        <IonHeader>
+            <IonToolbar>
+                <IonButtons slot="start">
+                    <IonBackButton text="" />
+                </IonButtons>
+                <IonTitle>Profile</IonTitle>
+            </IonToolbar>
+        </IonHeader>
+        <IonContent>
+            <div class="flex flex-col items-center mt-8">
+                <div class="w-[72px] h-[72px] relative">
+                    <img
+                        class="w-[72px] h-[72px] object-cover rounded-full"
+                        :src="avatar"
+                        alt="avatar"
+                    />
+                    <div
+                        class="absolute bottom-0 right-0 rounded-full bg-white text-black w-[20px] h-[20px] flex justify-center items-center"
+                    >
+                        <IonIcon class="text-sm" :icon="cameraOutline" />
+                    </div>
+                    <input
+                        type="file"
+                        accept="image/*"
+                        class="absolute w-full h-full opacity-0 bottom-0 right-0"
+                        @change="onChooseFile"
+                    />
+                </div>
+                <div class="mt-4 text-xs text-neutral-500">
+                    Current Logged In Email
+                </div>
+                <div class="text-sm">{{ user?.email }}</div>
+
+                <div class="w-[250px]">
+                    <input
+                        v-model="username"
+                        placeholder="username"
+                        class="w-full h-10 mt-6 rounded-md px-4 bg-neutral-800 active:border-2 focus:border-2 border-prim focus:border-prim outline-none"
+                    />
+
+                    <button
+                        class="mt-6 w-full h-10 bg-prim text-white rounded-md disabled:bg-opacity-50 disabled:text-opacity-50"
+                        :disabled="disabled"
+                        @click="save"
+                    >
+                        Save
+                    </button>
+                </div>
+            </div>
+        </IonContent>
+    </IonPage>
+</template>
+<script setup lang="ts">
+import { computed, ref, watch } from "vue";
+import {
+    IonPage,
+    IonContent,
+    IonHeader,
+    IonToolbar,
+    IonTitle,
+    IonLabel,
+    IonButtons,
+    IonBackButton,
+    IonIcon,
+} from "@ionic/vue";
+import { useUserStore } from "@/store/user";
+import { storeToRefs } from "pinia";
+import IconAvatar from "@/assets/icon_avatar.png";
+import { cameraOutline } from "ionicons/icons";
+import http from "@/plugins/http";
+import toast from "@/plugins/toast";
+const userStore = useUserStore();
+const { user } = storeToRefs(userStore);
+const { getUser } = userStore;
+const username = ref("");
+const avatar = ref("");
+username.value = user.value?.username || "";
+avatar.value = user.value?.avatar || IconAvatar;
+const disabled = computed(() => {
+    return username.value === user.value?.username;
+});
+async function onChooseFile(e: any) {
+    console.log("onChooseFile", e);
+    if (e.target.files.length > 0) {
+        const file = e.target.files[0];
+        console.log("file", file);
+        const formData = new FormData();
+        formData.append("file", file);
+        const { url } = await http.post("/files/upload", formData);
+        avatar.value = url;
+
+        await http.put(`/users/${user.value?.id}`, { avatar: url });
+        getUser();
+    }
+}
+
+async function save() {
+    try {
+        await http.put(`/users/${user.value?.id}`, {
+            username: username.value,
+        });
+        getUser();
+        toast("Saved", { type: "success" });
+    } catch (error: any) {
+        console.error(error.error[0]?.message);
+    }
+}
+</script>
+<style lang="less" scoped></style>

+ 34 - 4
src/views/SeriesView.vue

@@ -34,7 +34,10 @@
                             :series="series"
                             :episode="item"
                             :episodes="episodes"
+                            :saved="saved"
+                            @save="save"
                             @choose-episode="onChooseEpisode"
+                            @ended="next"
                         />
                     </swiper-slide>
                 </VueSwiper>
@@ -43,7 +46,7 @@
     </ion-page>
 </template>
 <script setup lang="ts">
-import { IonPage, IonContent, useIonRouter } from "@ionic/vue";
+import { IonPage, IonContent, useIonRouter, IonModal } from "@ionic/vue";
 import { Swiper as VueSwiper, SwiperSlide } from "swiper/vue";
 import { Swiper } from "swiper";
 import {
@@ -71,15 +74,15 @@ const activeSlide = ref(0);
 
 const route = useRoute();
 const router = useIonRouter();
-
+const seriesId = route.params.id;
 const series = ref<any>(null);
 const episodes = ref<any[]>([]);
 
 onMounted(() => {
-    http.get(`/series/${route.params.id}`).then((res) => {
+    http.get(`/series/${seriesId}`).then((res) => {
         series.value = res;
         http.get(`/episodes`, {
-            seriesId: route.params.id,
+            seriesId: seriesId,
             order: "episodeNum",
             pageSize: 1000,
         }).then((res) => {
@@ -89,7 +92,11 @@ onMounted(() => {
             });
         });
     });
+    http.get(`/collections/${seriesId}`).then((res) => {
+        saved.value = true;
+    });
 });
+
 function onSlideChange(e: any) {
     activeSlide.value = e.activeIndex;
 }
@@ -97,5 +104,28 @@ function onSlideChange(e: any) {
 function onChooseEpisode(n: number) {
     swiper.value?.slideTo(n, 0);
 }
+
+const saved = ref(false);
+
+function save() {
+    if (saved.value) {
+        http.delete(`/collections/${seriesId}`).then(() => {
+            saved.value = false;
+        });
+    } else {
+        http.post(`/collections`, {
+            seriesId: seriesId,
+            curEpId: episodes.value[activeSlide.value]?.id,
+        }).then(() => {
+            saved.value = true;
+        });
+    }
+}
+
+function next() {
+    if (activeSlide.value < episodes.value.length - 1) {
+        swiper.value?.slideTo(activeSlide.value + 1);
+    }
+}
 </script>
 <style lang="less" scoped></style>

+ 98 - 0
src/views/SettingsView.vue

@@ -0,0 +1,98 @@
+<template>
+    <IonPage>
+        <IonHeader>
+            <IonToolbar>
+                <IonButtons slot="start">
+                    <IonBackButton text="" />
+                </IonButtons>
+                <IonTitle>Settings</IonTitle>
+            </IonToolbar>
+        </IonHeader>
+        <IonContent>
+            <div class="mt-8 px-4 rounded-md">
+                <IonItemGroup class="bg-neutral-900 rounded-md">
+                    <ion-item detail button @click="router.push('/profile')">
+                        <ion-label> Profile </ion-label>
+                    </ion-item>
+                    <ion-item detail button>
+                        <ion-label> Privacy and Terms </ion-label>
+                    </ion-item>
+                </IonItemGroup>
+                <IonButton
+                    v-if="user && !user.visitor"
+                    id="open-action-sheet"
+                    class="mt-8 btn-logout"
+                    color="light"
+                    expand="block"
+                >
+                    Log out
+                </IonButton>
+            </div>
+            <ion-action-sheet
+                v-if="user && !user.visitor"
+                trigger="open-action-sheet"
+                header="Are you sure you want to log out?"
+                :buttons="actionSheetButtons"
+            ></ion-action-sheet>
+        </IonContent>
+    </IonPage>
+</template>
+<script setup lang="ts">
+import {
+    IonPage,
+    IonContent,
+    IonHeader,
+    IonToolbar,
+    IonTitle,
+    IonButtons,
+    IonBackButton,
+    IonItem,
+    IonItemGroup,
+    IonLabel,
+    IonButton,
+    IonActionSheet,
+    useIonRouter,
+} from "@ionic/vue";
+import { useUserStore } from "@/store/user";
+import { storeToRefs } from "pinia";
+import http from "@/plugins/http";
+const userStore = useUserStore();
+const { setUser } = userStore;
+const { user } = storeToRefs(userStore);
+const router = useIonRouter();
+const actionSheetButtons = [
+    {
+        text: "Log out",
+        role: "destructive",
+        data: {
+            action: "share",
+        },
+        handler: async () => {
+            setUser(null);
+            http.setToken(null);
+            const { token, user } = await http.get("/users/guest");
+            setUser(user);
+            http.setToken(token);
+        },
+    },
+    {
+        text: "Cancel",
+        role: "cancel",
+        data: {
+            action: "cancel",
+        },
+    },
+];
+</script>
+<style lang="less" scoped>
+ion-item {
+    --background: transparent;
+    --min-height: 54px;
+    &:last-child {
+        --border-style: none;
+    }
+}
+.btn-logout {
+    --border-radius: 6px;
+}
+</style>

+ 12 - 1
tailwind.config.js

@@ -2,12 +2,23 @@
 export default {
     content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
     theme: {
+        screens: {
+            xs: "375px",
+            sm: "640px",
+            md: "768px",
+            lg: "1024px",
+            xl: "1280px",
+            "2xl": "1536px",
+        },
         extend: {
             colors: {
                 prim: "#FF136A",
             },
         },
     },
-    plugins: [],
+    corePlugins: {
+        aspectRatio: false,
+    },
+    plugins: [require("@tailwindcss/aspect-ratio")],
     darkMode: "class",
 };

Разлика између датотеке није приказан због своје велике величине
+ 622 - 14
yarn.lock


Неке датотеке нису приказане због велике количине промена