guoziyun 1 rok pred
rodič
commit
8ebba072ac

+ 19 - 0
config/meta.js

@@ -192,6 +192,23 @@ let playDescription = {
 }
 
 
+let videosTitle = {
+  zh: '有趣又引人入胜的填色页视频故事',
+  en: 'Fun and Engaging Coloring Pages Video Stories',
+  es: 'Historias de videos de páginas para colorear divertidas y atractivas',
+  pt: 'Histórias de vídeos de páginas para colorir divertidas e envolventes',
+  ja: '面白く魅力的な塗り絵ページの動画物語',
+}
+
+let videosDescription = {
+  zh: '通过填色页视频故事,体验前所未有的故事讲述方式',
+  en: `Experience storytelling like never before with Coloring Pages Video Stories`,
+  es: 'Experimenta la narración de historias como nunca antes con las Historias de Videos de Páginas para Colorear.',
+  pt: 'Experimente contar histórias como nunca antes com as Histórias de Vídeos de Páginas para Colorir.',
+  ja: '塗り絵ページの動画物語を通じて、これまでにない物語体験をしてみましょう',
+}
+
+
 
 
 let meta = {
@@ -219,6 +236,8 @@ let meta = {
   detailDescription,
   playTitle,
   playDescription,
+  videosTitle,
+  videosDescription,
 }
 
 

+ 18 - 0
config/translate.js

@@ -281,6 +281,22 @@ let selectAlbums = {
   ja: '着色用ページのアルバム',
 }
 
+let videoStories = {
+  zh: '视频填色页',
+  en: 'Video Coloring Pages',
+  es: 'Páginas de colorear de vídeo',
+  pt: 'Páginas de colorir de vídeo',
+  ja: '動画塗り絵ページ',
+}
+
+let videoStory = {
+  zh: '视频填色页',
+  en: 'Video Coloring Page',
+  es: 'Página de colorear de vídeo',
+  pt: 'Página de colorir de vídeo',
+  ja: '動画塗り絵ページ',
+}
+
 let total = {
   zh: '共',
   en: 'total',
@@ -520,6 +536,8 @@ let translate = {
   hot,
   special,
   selectAlbums,
+  videoStory,
+  videoStories,
   total,
   item,
   page,

+ 15 - 0
dist/assets/svg/play-button.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" 
+	 viewBox="0 0 481 481" xml:space="preserve">
+<g>
+	<g>
+		<path d="M410.6,70.4C365.1,25,304.7,0,240.5,0S115.9,25,70.4,70.4C25,115.9,0,176.3,0,240.5s25,124.6,70.4,170.1
+			C115.8,456,176.2,481,240.5,481s124.6-25,170.1-70.4C456,365.2,481,304.8,481,240.5S456,115.9,410.6,70.4z M240.5,454
+			C122.8,454,27,358.2,27,240.5S122.8,27,240.5,27S454,122.8,454,240.5S358.2,454,240.5,454z"/>
+		<path d="M349.2,229.1l-152.6-97.9c-4.2-2.7-9.4-2.9-13.8-0.5c-4.3,2.4-7,6.9-7,11.8v195.7c0,4.9,2.7,9.5,7,11.8
+			c2,1.1,4.3,1.7,6.5,1.7c2.5,0,5.1-0.7,7.3-2.1l152.6-97.9c3.9-2.5,6.2-6.8,6.2-11.4S353,231.6,349.2,229.1z M202.8,313.7V167.3
+			l114.1,73.2L202.8,313.7z"/>
+	</g>
+</g>
+</svg>

+ 53 - 0
dist/stylesheets/styles.css

@@ -88,6 +88,7 @@ body {
 }
 
 .album-grid-card {
+  position: relative;
   padding: 5px 0px 5px 0px;
   border: 1px solid #ccc;
   border-radius: 8px;
@@ -172,3 +173,55 @@ body {
   }
 
 }
+
+.play-button {
+  position: absolute;
+  top: 5px;
+  right: 5px;
+}
+
+
+.popup {
+  display: none; /* 默认隐藏 */
+  position: fixed;
+  z-index: 1000;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+  overflow: auto;
+  background-color: rgba(0, 0, 0, 0.8);
+}
+
+.popup-content {
+  background-color: #fefefe;
+  margin: 15% auto;
+  padding: 20px;
+  border: 1px solid #888;
+  width: 80%;
+  max-width: 800px;
+  text-align: center;
+  position: relative;
+}
+
+.close {
+  color: #aaa;
+  position: absolute;
+  top: 10px;
+  right: 10px;
+  font-size: 28px;
+  font-weight: bold;
+  cursor: pointer;
+}
+
+.close:hover,
+.close:focus {
+  color: black;
+  text-decoration: none;
+}
+
+#video-player {
+  width: 100%;
+  max-width: 640px;
+  height: auto;
+}

+ 252 - 4
routes/index.js

@@ -136,6 +136,39 @@ router.get(/^\/(en|zh|es|pt|ja)$/, function (req, res, next) {  // 限制严格
         doc.size = doc.contents.length;
       }
 
+      // 视频故事
+      let videos = await models.ArtVideoStory
+        .find({ enabled: true, seoTitle: { $exists: true } })
+        .sort({ order: 'asc' })
+        .limit(6)
+        .lean()
+        .exec();
+
+      for (let doc of videos) {
+        doc.poster = `${host}/thumbs/coloring-page/vs-poster/320/${doc._id}.${imageType}`;
+        doc.size = doc.contents.length;
+        if (doc.seoTitle) {
+          try {
+            let titleJson = JSON.parse(doc.seoTitle);
+            doc.seoTitle = titleJson && titleJson[lang] ? titleJson[lang] : doc.name;
+          } catch (e) {
+            console.error(e.message);
+          }
+        } else {
+          doc.seoTitle = doc.name;
+        }
+        if (doc.seoDescription) {
+          try {
+            let descJson = JSON.parse(doc.seoDescription);
+            doc.seoDescription = descJson && descJson[lang] ? descJson[lang] : doc.seoTitle;
+          } catch (e) {
+            console.error(e.message);
+          }
+        } else {
+          doc.seoDescription = doc.seoTitle;
+        }
+      }
+
       // 设计师
       let designers = await models.Art.aggregate([
         // 首先,过滤出 status = 9000 的文档
@@ -181,6 +214,7 @@ router.get(/^\/(en|zh|es|pt|ja)$/, function (req, res, next) {  // 限制严格
         recommend,
         special,
         albums,
+        videos,
         designers,
         translate,
         categories,
@@ -537,7 +571,7 @@ router.get('/:lang/albums', function (req, res, next) {
         .sort({ order: 'asc' })
         .populate('title')
         .populate('slogon')
-        .select('tag title slogon contents')
+        .select('tag title slogon seoTitle seoDescription contents')
         .lean()
         .exec();
 
@@ -548,6 +582,26 @@ router.get('/:lang/albums', function (req, res, next) {
         doc.title = doc.title ? doc.title[lang] : '';
         doc.slogon = doc.slogon ? doc.slogon[lang] : '';
         doc.size = doc.contents.length;
+        if (doc.seoTitle) {
+          try {
+            let titleJson = JSON.parse(doc.seoTitle);
+            doc.seoTitle = titleJson && titleJson[lang] ? titleJson[lang] : doc.title;
+          } catch (e) {
+            console.error(e.message);
+          }
+        } else {
+          doc.seoTitle = doc.title;
+        }
+        if (doc.seoDescription) {
+          try {
+            let descJson = JSON.parse(doc.seoDescription);
+            doc.seoDescription = descJson && descJson[lang] ? descJson[lang] : doc.slogon;
+          } catch (e) {
+            console.error(e.message);
+          }
+        } else {
+          doc.seoDescription = doc.slogon;
+        }
       }
 
       let data = {
@@ -618,7 +672,7 @@ router.get('/:lang/coloring-page-album/:id', function (req, res, next) {
         .populate('title')
         .populate('slogon')
         .populate({ path: 'contents', select: artSelect })
-        .select('tag title slogon contents')
+        .select('tag title slogon seoTitle seoDescription contents')
         .lean()
         .exec();
 
@@ -630,13 +684,33 @@ router.get('/:lang/coloring-page-album/:id', function (req, res, next) {
       doc.title = doc.title ? doc.title[lang] : '';
       doc.slogon = doc.slogon ? doc.slogon[lang] : '';
       doc.size = doc.contents.length;
+      if (doc.seoTitle) {
+        try {
+          let titleJson = JSON.parse(doc.seoTitle);
+          doc.seoTitle = titleJson && titleJson[lang] ? titleJson[lang] : doc.title;
+        } catch (e) {
+          console.error(e.message);
+        }
+      } else {
+        doc.seoTitle = doc.title;
+      }
+      if (doc.seoDescription) {
+        try {
+          let descJson = JSON.parse(doc.seoDescription);
+          doc.seoDescription = descJson && descJson[lang] ? descJson[lang] : doc.slogon;
+        } catch (e) {
+          console.error(e.message);
+        }
+      } else {
+        doc.seoDescription = doc.slogon;
+      }
 
       organizeData(doc.contents, lang, imageType);
 
 
       let data = {
-        title: `${translate.coloringPageAlbum[lang]}: ${doc.title}`,
-        description: `${doc.slogon}`,
+        title: doc.seoTitle,
+        description: doc.seoDescription,
         data: doc,
         translate,
         languages,
@@ -672,6 +746,180 @@ router.get('/:lang/coloring-page-album/:id', function (req, res, next) {
 });
 
 
+// 视频故事页路由
+router.get('/:lang/videos', function (req, res, next) {
+  (async function () {
+    let lang = utils.lang.ensureLanguage(req.params.lang);
+    if (!req.cookies.lang || req.cookies.lang != lang) {
+      res.cookie('lang', lang, { maxAge: 900000, httpOnly: true });
+    }
+
+    let imageType = req.headers.accept?.includes('image/webp') ? 'webp' : 'jpeg';  // 浏览器支持webp就用webp
+
+    let cacheKey = `${CACHE_PREFIX}_${imageType}_${req.originalUrl}`;
+    let htmlData = await redis.getAsync(cacheKey);
+    if (!htmlData) {
+      // 视频故事
+      let vidoes = await models.ArtVideoStory
+        .find({ enabled: true, seoTitle: { $exists: true } })
+        .sort({ order: 'asc' })
+        .lean()
+        .exec();
+
+      let host = config.cdnHost ?? config.resHost;
+      for (let doc of videos) {
+        doc.poster = `${host}/thumbs/coloring-page/vs-poster/320/${doc._id}.${imageType}`;
+        doc.size = doc.contents.length;
+        if (doc.seoTitle) {
+          try {
+            let titleJson = JSON.parse(doc.seoTitle);
+            doc.seoTitle = titleJson && titleJson[lang] ? titleJson[lang] : doc.name;
+          } catch (e) {
+            console.error(e.message);
+          }
+        } else {
+          doc.seoTitle = doc.name;
+        }
+        if (doc.seoDescription) {
+          try {
+            let descJson = JSON.parse(doc.seoDescription);
+            doc.seoDescription = descJson && descJson[lang] ? descJson[lang] : doc.seoTitle;
+          } catch (e) {
+            console.error(e.message);
+          }
+        } else {
+          doc.seoDescription = doc.seoTitle;
+        }
+      }
+
+      let data = {
+        title: meta.videosTitle[lang],
+        description: meta.videosDescription[lang],
+        data: videos,
+        length: videos.length,
+        translate,
+        languages,
+        lang,
+        uri: req.originalUrl,
+        pageUri: replaceUriParams,
+      };
+
+      // 渲染EJS模板到内存中
+      res.render('videos', data, async (err, html) => {
+        if (err) {
+          // 如果渲染出错,调用next()传递错误
+          return next(err);
+        }
+
+        // 渲染成功,存redis, 发送数据到客户端
+        htmlData = html;
+        try {
+          await redis.set(cacheKey, htmlData, 'EX', CACHE_EXPIRES);
+        } catch (e) {
+          console.error(e);
+        }
+
+        res.send(htmlData);
+      });
+    } else {
+      // 缓存命中, 直接发送缓存数据
+      res.set({ 'X-From-Cache': 'true' });
+      res.send(htmlData);
+    }
+
+  })().catch(next);
+
+});
+
+// 视频故事详情页路由
+router.get('/:lang/coloring-page-video/:id', function (req, res, next) {
+  (async function () {
+    let lang = utils.lang.ensureLanguage(req.params.lang);
+    if (!req.cookies.lang || req.cookies.lang != lang) {
+      res.cookie('lang', lang, config.cookie);
+    }
+
+    let id = req.params.id;
+    utils.validators.validateId(id);
+
+    let imageType = req.headers.accept?.includes('image/webp') ? 'webp' : 'jpeg';  // 浏览器支持webp就用webp
+
+    let cacheKey = `${CACHE_PREFIX}_${imageType}_${req.originalUrl}`;
+    let htmlData = await redis.getAsync(cacheKey);
+    if (!htmlData) {
+      // 专辑
+      let doc = await models.ArtVideoStory
+        .findById(id)
+        .lean()
+        .exec();
+
+      if (!doc) throw createError(404, 'Album Not Found!');
+
+      let host = config.cdnHost ?? config.resHost;
+      doc.poster = `${host}/thumbs/coloring-page/vs-poster/480/${doc._id}.${imageType}`;
+      doc.size = doc.contents.length;
+      if (doc.seoTitle) {
+        try {
+          let titleJson = JSON.parse(doc.seoTitle);
+          doc.seoTitle = titleJson && titleJson[lang] ? titleJson[lang] : doc.name;
+        } catch (e) {
+          console.error(e.message);
+        }
+      } else {
+        doc.seoTitle = doc.title;
+      }
+      if (doc.seoDescription) {
+        try {
+          let descJson = JSON.parse(doc.seoDescription);
+          doc.seoDescription = descJson && descJson[lang] ? descJson[lang] : doc.seoTitle;
+        } catch (e) {
+          console.error(e.message);
+        }
+      } else {
+        doc.seoDescription = doc.seoTitle;
+      }
+
+      organizeData(doc.contents, lang, imageType);
+
+
+      let data = {
+        title: doc.seoTitle,
+        description: doc.seoDescription,
+        data: doc,
+        translate,
+        languages,
+        lang,
+        uri: req.originalUrl,
+        pageUri: replaceUriParams,
+      };
+      // 渲染EJS模板到内存中
+      res.render('video', data, async (err, html) => {
+        if (err) {
+          // 如果渲染出错,调用next()传递错误
+          return next(err);
+        }
+
+        // 渲染成功,存redis, 发送数据到客户端
+        htmlData = html;
+        try {
+          await redis.set(cacheKey, htmlData, 'EX', CACHE_EXPIRES);
+        } catch (e) {
+          console.error(e);
+        }
+
+        res.send(htmlData);
+      });
+    } else {
+      // 缓存命中, 直接发送缓存数据
+      res.set({ 'X-From-Cache': 'true' });
+      res.send(htmlData);
+    }
+
+  })().catch(next);
+
+});
+
+
 // 设计师专栏路由, 重定向到新的页面
 router.get('/:lang/designers', function (req, res, next) {
   const uri = req.originalUrl;

+ 1 - 1
service/cron-jobs/fetch-meta.js

@@ -193,7 +193,7 @@ async function runArtMeta() {
 
 
   // 筛选出所有已经ready并且还没有title的图
-  let query = { status: { $gte: 7000 }, open: true, $or: [{ copy: { $exists: false } }, { copy: null }] };
+  let query = { status: { $gte: 7000 }, $or: [{ copy: { $exists: false } }, { copy: null }] };
   let docs = await models.Art.find(query).sort({ publishTime: 'desc' });  // 内存有限,每次跑1000个
 
 

+ 3 - 1
views/album.ejs

@@ -71,7 +71,9 @@
         <% data.contents.forEach(item=> { %>
           <div class="image-card">
             <a href="<%= item.uri %>"><img src="<%= item.thumb %>" alt="<%= item.title %>"></a>
-            <div class="card-title"><%= item.title %></div>
+            <div class="card-title">
+              <%= item.title %>
+            </div>
           </div>
           <% }); %>
       </div>

+ 10 - 9
views/index.ejs

@@ -49,15 +49,16 @@
 
 <body>
   <%- include('header') %>
-    <%- include('intro-section') %>
-      <%- include('latest-section') %>
-        <%- include('album-section') %>
-          <%- include('hot-section') %>
-            <%- include('designer-section') %>
-              <%- include('special-section') %>
-                <%- include('footer') %>
-                  <%- include('cookie-banner') %>
-                    <div style="height: 50px;"></div>
+    <!-- <%- include('intro-section') %> -->
+    <%- include('video-story-section') %>
+    <%- include('latest-section') %>
+    <%- include('album-section') %>
+    <%- include('hot-section') %>
+    <%- include('designer-section') %>
+    <%- include('special-section') %>
+    <%- include('footer') %>
+    <%- include('cookie-banner') %>
+    <div style="height: 50px;"></div>
 </body>
 
 

+ 85 - 0
views/video-story-section.ejs

@@ -0,0 +1,85 @@
+<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
+<div class="content-wrapper">
+  <div class="content-title">
+    <h2>
+      <%= translate.videoStories[lang] %>:
+    </h2>
+    <a href="<%= lang %>/videos">
+      <%= translate.more[lang] %> >>>
+    </a>
+  </div>
+
+  <div class="content">
+    <div class="album-icon-grid">
+      <% videos.forEach(video=> { %>
+        <div class="album-grid-card">
+          <div style="padding: 2px; font-size: 14px; color: grey; text-align: center; white-space: nowrap;">
+            <%= translate.videoStory[lang] %>
+          </div>
+          <a href="javascript:;" onclick="onPlay('<%= video.url %>')">
+            <img src="<%= video.poster %>" class="album-icon-img" alt="<%= video.seoTitle %>">
+            <img src="/assets/svg/play-button.svg" , class="play-button" width="20px" height="20px"
+              alt="Coloring Page Video Play Button">
+          </a>
+          <!-- <video id="video" controls>
+            <source src="<%= video.url %>" type="application/x-mpegURL">
+          </video> -->
+        </div>
+        <% }); %>
+    </div>
+  </div>
+
+</div>
+
+<!-- 弹出层 -->
+<div id="video-popup" class="popup">
+  <div class="popup-content">
+    <span class="close" onclick="closeVideoPopup()">&times;</span>
+    <video id="video-player" width="400" height="500" controls></video>
+  </div>
+</div>
+
+<script>
+  function onPlay(url) {
+    var videoPopup = document.getElementById('video-popup');
+    var videoPlayer = document.getElementById('video-player');
+
+    videoPopup.style.display = 'block';
+
+    if (Hls.isSupported()) {
+      var hls = new Hls();
+      hls.loadSource(url);
+      hls.attachMedia(videoPlayer);
+      hls.on(Hls.Events.MANIFEST_PARSED, function () {
+        videoPlayer.play();
+      });
+    } else if (videoPlayer.canPlayType('application/vnd.apple.mpegurl')) {
+      videoPlayer.src = url;
+      videoPlayer.addEventListener('canplay', function () {
+        videoPlayer.play();
+      });
+    }
+
+    window.closeVideoPopup = function () {
+      videoPopup.style.display = 'none';
+      videoPlayer.pause(); // 停止视频播放
+      videoPlayer.src = ''; // 重置视频源,避免浏览器缓存问题
+    };
+
+    // 点击模态对话框外部时关闭对话框(可选,但推荐添加)
+    videoPopup.addEventListener('click', function (event) {
+      if (event.target === videoPopup) {
+        closeVideoPopup();
+      }
+    });
+
+    // 防止点击关闭按钮时事件冒泡到弹出层导致关闭(因为我们已经为关闭按钮单独绑定了事件)
+    var closeButton = document.querySelector('.close');
+    if (closeButton) {
+      closeButton.addEventListener('click', function (event) {
+        event.stopPropagation();
+      });
+    }
+  }
+
+</script>