diff --git a/.circleci/config.yml b/.circleci/config.yml index 0c04d93..0500ccd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,4 +1,5 @@ version: 2 + jobs: build: docker: @@ -67,7 +68,14 @@ jobs: - run: command: | mkdir -p /tmp/phpunit - ./vendor/bin/phpunit --log-junit /tmp/phpunit/phpunit.xml + ./vendor/bin/phpunit --log-junit /tmp/phpunit/phpunit.xml --coverage-clover=/tmp/phpunit/coverage.xml when: always - store_test_results: path: /tmp/phpunit + - store_artifacts: + path: /tmp/phpunit/coverage.xml + + # Upload coverage + - run: + command: bash <(curl -s https://codecov.io/bash) -f /tmp/phpunit/coverage.xml + when: always diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b27771b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +charset = utf-8 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[*.yml] +indent_size = 2 + +[*.json] +indent_size = 2 + +[composer.json] +indent_size = 4 diff --git a/.env.example b/.env.example index a41650b..78ff0c5 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,9 @@ APP_DEBUG=true APP_LOG_LEVEL=debug APP_URL=http://localhost +# テストにモックを使用するか falseの場合は実際のHTML等を取得してテストする +TEST_USE_HTTP_MOCK=true + DB_CONNECTION=pgsql DB_HOST=db DB_PORT=5432 diff --git a/.stylelintignore b/.stylelintignore new file mode 100644 index 0000000..0e97cba --- /dev/null +++ b/.stylelintignore @@ -0,0 +1 @@ +/tests/fixture/* diff --git a/README.md b/README.md index 9567de3..8e41439 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ docker-compose up -d 4. Composer と yarn を使い必要なライブラリをインストールします。 ``` +docker-compose exec web composer global require hirak/prestissimo docker-compose exec web composer install docker-compose exec web yarn install ``` @@ -50,7 +51,7 @@ docker-compose exec web php artisan migrate 6. ファイルに書き込めるように権限を設定します。 ``` -docker-compose exec web chown -R www-data /var/www/html +docker-compose exec web chown -R www-data /var/www/html/storage ``` 7. アセットをビルドします。 diff --git a/app/Http/Controllers/Api/CardController.php b/app/Http/Controllers/Api/CardController.php index e5578d2..a336830 100644 --- a/app/Http/Controllers/Api/CardController.php +++ b/app/Http/Controllers/Api/CardController.php @@ -51,6 +51,8 @@ class CardController $metadata->tags()->sync($tagIds); } + $metadata->load('tags'); + $response = response($metadata); if (!config('app.debug')) { $response = $response->setCache(['public' => true, 'max_age' => 86400]); diff --git a/app/Http/ViewComposers/ProfileStatsComposer.php b/app/Http/ViewComposers/ProfileStatsComposer.php index 6f93ded..ae632a2 100644 --- a/app/Http/ViewComposers/ProfileStatsComposer.php +++ b/app/Http/ViewComposers/ProfileStatsComposer.php @@ -35,9 +35,27 @@ class ProfileStatsComposer } // 概況欄のデータ取得 + $average = DB::select(<<<'SQL' +SELECT + avg(span) AS average +FROM + ( + SELECT + extract(epoch from ejaculated_date - lead(ejaculated_date, 1, NULL) OVER (ORDER BY ejaculated_date DESC)) AS span + FROM + ejaculations + WHERE + user_id = :user_id + ORDER BY + ejaculated_date DESC + LIMIT + 30 + ) AS temp +SQL + , ['user_id' => $user->id]); + $summary = DB::select(<<<'SQL' SELECT - avg(span) AS average, max(span) AS longest, min(span) AS shortest, sum(span) AS total_times, @@ -56,6 +74,6 @@ FROM SQL , ['user_id' => $user->id]); - $view->with(compact('latestEjaculation', 'currentSession', 'summary')); + $view->with(compact('latestEjaculation', 'currentSession', 'average', 'summary')); } } diff --git a/app/Metadata.php b/app/Metadata.php index d516b0b..2abd321 100644 --- a/app/Metadata.php +++ b/app/Metadata.php @@ -11,7 +11,7 @@ class Metadata extends Model protected $keyType = 'string'; protected $fillable = ['url', 'title', 'description', 'image', 'expires_at']; - protected $visible = ['url', 'title', 'description', 'image', 'expires_at']; + protected $visible = ['url', 'title', 'description', 'image', 'expires_at', 'tags']; protected $dates = ['created_at', 'updated_at', 'expires_at']; diff --git a/app/MetadataResolver/DLsiteResolver.php b/app/MetadataResolver/DLsiteResolver.php index 9f6d6e7..32e2769 100644 --- a/app/MetadataResolver/DLsiteResolver.php +++ b/app/MetadataResolver/DLsiteResolver.php @@ -21,23 +21,91 @@ class DLsiteResolver implements Resolver $this->ogpResolver = $ogpResolver; } + /** + * HTMLからタグとして利用可能な情報を抽出する + * @param string $html ページ HTML + * @return string[] タグ + */ + public function extractTags(string $html): array + { + $dom = new \DOMDocument(); + @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')); + $xpath = new \DOMXPath($dom); + + $genreNode = $xpath->query("//div[@class='main_genre'][1]"); + if ($genreNode->length === 0) { + return []; + } + + $tagsNode = $genreNode->item(0)->getElementsByTagName('a'); + $tags = []; + + for ($i = 0; $i <= $tagsNode->length - 1; $i++) { + $tags[] = $tagsNode->item($i)->textContent; + } + + // 重複削除 + $tags = array_values(array_unique($tags)); + + return $tags; + } + public function resolve(string $url): Metadata { + + //スマホページの場合はPCページに正規化 + if (strpos($url, '-touch') !== false) { + $url = str_replace('-touch', '', $url); + } + $res = $this->client->get($url); if ($res->getStatusCode() === 200) { $metadata = $this->ogpResolver->parse($res->getBody()); - // 抽出 - preg_match('~\[(.+)\] \| DLsite$~', $metadata->title, $match); - $maker = $match[1]; + $dom = new \DOMDocument(); + @$dom->loadHTML(mb_convert_encoding($res->getBody(), 'HTML-ENTITIES', 'UTF-8')); + $xpath = new \DOMXPath($dom); + + // OGPタイトルから[]に囲まれているmakerを取得する + // 複数の作者がいる場合スペース区切りになるためexplodeしている + // スペースを含むmakerの場合名前の一部しか取れないが動作には問題ない + preg_match('~ \[([^\[\]]*)\] (予告作品 )?\| DLsite(がるまに)?$~', $metadata->title, $match); + $makers = explode(' ', $match[1]); + + //フォローボタン(.btn_follow)はテキストを含んでしまうことがあるので要素を削除しておく + $followButtonNode = $xpath->query('//*[@class="btn_follow"]')->item(0); + $followButtonNode->parentNode->removeChild($followButtonNode); + + // maker, makerHeadを探す + + // makers + // #work_makerから「makerを含むテキスト」を持つ要素を持つtdを探す + // 作者名単体の場合もあるし、"作者A / 作者B"のようになることもある + $makersNode = $xpath->query('//*[@id="work_maker"]//*[contains(text(), "' . $makers[0] . '")]/ancestor::td')->item(0); + $makers = trim($makersNode->textContent); + + // makersHaed + // $makerNode(td)に対するthを探す + // "著者", "サークル名", "ブランド名"など + $makersHeadNode = $xpath->query('preceding-sibling::th', $makersNode)->item(0); + $makersHead = trim($makersHeadNode->textContent); // 余分な文を消す - $metadata->title = trim(preg_replace('~ \[.+\] \| DLsite$~', '', $metadata->title)); - $metadata->description = trim(preg_replace('~「DLsite.+」は同人誌・同人ゲーム・同人音声のダウンロードショップ。お気に入りの作品をすぐダウンロードできてすぐ楽しめる!毎日更新しているのであなたが探している作品にきっと出会えます。国内最大級の二次元総合ダウンロードショップ「DLsite」!$~', '', $metadata->description)); + + // OGPタイトルから作者名とサイト名を消す + $metadata->title = trim(preg_replace('~ \[[^\[\]]*\] (予告作品 )?\| DLsite(がるまに)?$~', '', $metadata->title)); + + // OGP説明文から定型文を消す + if (strpos($url, 'dlsite.com/eng/') || strpos($url, 'dlsite.com/ecchi-eng/')) { + $metadata->description = trim(preg_replace('~DLsite.+ is a download shop for .+With a huge selection of products, we\'re sure you\'ll find whatever tickles your fancy\. DLsite is one of the greatest indie contents download shops in Japan\.$~', '', $metadata->description)); + } else { + $metadata->description = trim(preg_replace('~「DLsite.+」は.+のダウンロードショップ。お気に入りの作品をすぐダウンロードできてすぐ楽しめる!毎日更新しているのであなたが探している作品にきっと出会えます。国内最大級の二次元総合ダウンロードショップ「DLsite」!$~', '', $metadata->description)); + } // 整形 - $metadata->description = 'サークル: ' . $maker . PHP_EOL . $metadata->description; + $metadata->description = $makersHead . ': ' . $makers . PHP_EOL . $metadata->description; $metadata->image = str_replace('img_sam.jpg', 'img_main.jpg', $metadata->image); + $metadata->tags = $this->extractTags($res->getBody()); return $metadata; } else { diff --git a/app/MetadataResolver/MetadataResolver.php b/app/MetadataResolver/MetadataResolver.php index 129899a..64cdefb 100644 --- a/app/MetadataResolver/MetadataResolver.php +++ b/app/MetadataResolver/MetadataResolver.php @@ -15,9 +15,10 @@ class MetadataResolver implements Resolver '~www\.melonbooks\.co\.jp/detail/detail\.php~' => MelonbooksResolver::class, '~ec\.toranoana\.(jp|shop)/(tora|joshi)(_[rd]+)?/(ec|digi)/item/~' => ToranoanaResolver::class, '~iwara\.tv/videos/.*~' => IwaraResolver::class, - '~www\.dlsite\.com/.*/work/=/product_id/..\d+(\.html)?~' => DLsiteResolver::class, - '~dlsite\.jp/mawtw/..\d+~' => DLsiteResolver::class, + '~www\.dlsite\.com/.*/(work|announce)/=/product_id/..\d+(\.html)?~' => DLsiteResolver::class, + '~dlsite\.jp/...tw/..\d+~' => DLsiteResolver::class, '~www\.pixiv\.net/member_illust\.php\?illust_id=\d+~' => PixivResolver::class, + '~www\.pixiv\.net/user/\d+/series/\d+~' => PixivResolver::class, '~fantia\.jp/posts/\d+~' => FantiaResolver::class, '~dmm\.co\.jp/~' => FanzaResolver::class, '~www\.patreon\.com/~' => PatreonResolver::class, @@ -26,6 +27,7 @@ class MetadataResolver implements Resolver '~ci-en\.jp/creator/\d+/article/\d+~' => CienResolver::class, '~www\.plurk\.com\/p\/.*~' => PlurkResolver::class, '~(adult\.)?contents\.fc2\.com\/article_search\.php\?id=\d+~' => FC2ContentsResolver::class, + '~store\.steampowered\.com/app/\d+~' => SteamResolver::class, ]; public $mimeTypes = [ diff --git a/app/MetadataResolver/PixivResolver.php b/app/MetadataResolver/PixivResolver.php index 69c9937..ff893a0 100644 --- a/app/MetadataResolver/PixivResolver.php +++ b/app/MetadataResolver/PixivResolver.php @@ -21,21 +21,6 @@ class PixivResolver implements Resolver $this->ogpResolver = $ogpResolver; } - /** - * サムネイル画像 URL から最大長辺 1200px の画像 URL に変換する - * - * @param string $thumbnailUrl サムネイル画像 URL - * - * @return string 1200px の画像 URL - */ - public function thumbnailToMasterUrl(string $thumbnailUrl): string - { - $temp = str_replace('/c/128x128', '', $thumbnailUrl); - $largeUrl = str_replace('square1200.jpg', 'master1200.jpg', $temp); - - return $largeUrl; - } - /** * 直リン可能な pixiv.cat のプロキシ URL に変換する * HUGE THANKS TO PIXIV.CAT! @@ -49,45 +34,20 @@ class PixivResolver implements Resolver return str_replace('i.pximg.net', 'i.pixiv.cat', $pixivUrl); } - /** - * HTMLからタグとして利用可能な情報を抽出する - * @param string $html ページ HTML - * @return string[] タグ - */ - public function extractTags(string $html): array - { - $dom = new \DOMDocument(); - @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')); - $xpath = new \DOMXPath($dom); - - $nodes = $xpath->query("//meta[@name='keywords']"); - if ($nodes->length === 0) { - return []; - } - - $keywords = $nodes->item(0)->getAttribute('content'); - $tags = []; - - foreach (mb_split(',', $keywords) as $keyword) { - $keyword = trim($keyword); - - if (empty($keyword)) { - continue; - } - - // 一部の固定キーワードは無視 - if (array_search($keyword, ['R-18', 'イラスト', 'pixiv', 'ピクシブ'], true)) { - continue; - } - - $tags[] = preg_replace('/\s/', '_', $keyword); - } - - return $tags; - } - public function resolve(string $url): Metadata { + if (preg_match('~www\.pixiv\.net/user/\d+/series/\d+~', $url, $matches)) { + $res = $this->client->get($url); + if ($res->getStatusCode() === 200) { + $metadata = $this->ogpResolver->parse($res->getBody()); + $metadata->image = $this->proxize($metadata->image); + + return $metadata; + } else { + throw new \RuntimeException("{$res->getStatusCode()}: $url"); + } + } + parse_str(parse_url($url, PHP_URL_QUERY), $params); $illustId = $params['illust_id']; $page = 0; @@ -95,27 +55,31 @@ class PixivResolver implements Resolver // 漫画ページ(ページ数はmanga_bigならあるかも) if ($params['mode'] === 'manga_big' || $params['mode'] === 'manga') { $page = $params['page'] ?? 0; - - // 未ログインでは漫画ページを開けないため、URL を作品ページに変換する - $url = preg_replace('~mode=manga(_big)?~', 'mode=medium', $url); } - $res = $this->client->get($url); + $res = $this->client->get('https://www.pixiv.net/ajax/illust/' . $illustId); if ($res->getStatusCode() === 200) { - $metadata = $this->ogpResolver->parse($res->getBody()); + $json = json_decode($res->getBody()->getContents(), true); + $metadata = new Metadata(); - preg_match("~https://i\.pximg\.net/c/128x128/img-master/img/\d{4}/\d{2}/\d{2}/\d{2}/\d{2}/\d{2}/{$illustId}(_p0)?_square1200\.jpg~", $res->getBody(), $match); - $illustThumbnailUrl = $match[0]; + $metadata->title = $json['body']['illustTitle'] ?? ''; + $metadata->description = '投稿者: ' . $json['body']['userName'] . PHP_EOL . strip_tags(str_replace('
', PHP_EOL, $json['body']['illustComment'] ?? '')); + $metadata->image = $this->proxize($json['body']['urls']['regular'] ?? ''); + // ページ数の指定がある場合は画像URLをそのページにする if ($page != 0) { - $illustThumbnailUrl = str_replace('_p0', '_p'.$page, $illustThumbnailUrl); + $metadata->image = str_replace('_p0', '_p'.$page, $metadata->image); } - $illustUrl = $this->thumbnailToMasterUrl($illustThumbnailUrl); - - $metadata->image = $this->proxize($illustUrl); - - $metadata->tags = $this->extractTags($res->getBody()); + // タグ + if (!empty($json['body']['tags']['tags'])) { + foreach ($json['body']['tags']['tags'] as $tag) { + // 一部の固定キーワードは無視 + if (array_search($tag['tag'], ['R-18', 'イラスト', 'pixiv', 'ピクシブ'], true) === false) { + $metadata->tags[] = preg_replace('/\s/', '_', $tag['tag']); + } + } + } return $metadata; } else { diff --git a/app/MetadataResolver/SteamResolver.php b/app/MetadataResolver/SteamResolver.php new file mode 100644 index 0000000..987c97b --- /dev/null +++ b/app/MetadataResolver/SteamResolver.php @@ -0,0 +1,44 @@ +client = $client; + } + + public function resolve(string $url): Metadata + { + if (preg_match('~store\.steampowered\.com/app/(\d+)~', $url, $matches) !== 1) { + throw new \RuntimeException("Unmatched URL Pattern: $url"); + } + $appid = $matches[1]; + + $res = $this->client->get('https://store.steampowered.com/api/appdetails/?l=japanese&appids=' . $appid); + if ($res->getStatusCode() === 200) { + $json = json_decode($res->getBody()->getContents(), true); + if ($json[$appid]['success'] === false) { + throw new \RuntimeException("API response [$appid][success] is false: $url"); + } + $data = $json[$appid]['data']; + $metadata = new Metadata(); + + $metadata->title = $data['name'] ?? ''; + $metadata->description = strip_tags(str_replace('
', PHP_EOL, html_entity_decode($data['short_description'] ?? ''))); + $metadata->image = $data['header_image'] ?? ''; + + return $metadata; + } else { + throw new \RuntimeException("{$res->getStatusCode()}: $url"); + } + } +} diff --git a/app/Tag.php b/app/Tag.php index 7d4cb9a..479d972 100644 --- a/app/Tag.php +++ b/app/Tag.php @@ -11,6 +11,9 @@ class Tag extends Model protected $fillable = [ 'name' ]; + protected $visible = [ + 'name' + ]; public function ejaculations() { diff --git a/app/User.php b/app/User.php index ec9c4d7..1cafaa2 100644 --- a/app/User.php +++ b/app/User.php @@ -41,7 +41,7 @@ class User extends Authenticatable { $hash = md5(strtolower(trim($this->email))); - return '//www.gravatar.com/avatar/' . $hash . '?s=' . $size; + return '//www.gravatar.com/avatar/' . $hash . '?s=' . $size . '&d=retro'; } /** diff --git a/app/Utilities/Formatter.php b/app/Utilities/Formatter.php index 8214894..0adee69 100644 --- a/app/Utilities/Formatter.php +++ b/app/Utilities/Formatter.php @@ -35,7 +35,7 @@ class Formatter */ public function linkify($text) { - return $this->linkify->processUrls($text); + return $this->linkify->processUrls($text, ['attr' => ['target' => '_blank', 'rel' => 'noopener']]); } /** diff --git a/composer.json b/composer.json index 95dda47..c4eee01 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "@php artisan package:discover" ], "fix": [ - "php-cs-fixer fix" + "php-cs-fixer fix --config=.php_cs.dist" ], "test": [ "phpunit" diff --git a/package.json b/package.json index 01fce83..76d71d7 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "stylelint": "stylelint resources/assets/sass/**/*" }, "devDependencies": { + "@types/jquery": "^3.3.29", "bootstrap": "^4.3.1", "cal-heatmap": "^3.3.10", "chart.js": "^2.7.1", @@ -28,7 +29,12 @@ "sass-loader": "^7.1.0", "stylelint": "^9.10.1", "stylelint-config-recess-order": "^2.0.1", - "vue-template-compiler": "^2.6.6" + "ts-loader": "^6.0.1", + "typescript": "^3.4.5", + "vue": "^2.6.10", + "vue-class-component": "^7.1.0", + "vue-property-decorator": "^8.1.1", + "vue-template-compiler": "^2.6.10" }, "stylelint": { "extends": "stylelint-config-recess-order" @@ -39,6 +45,13 @@ } }, "lint-staged": { - "*.{css,scss}": ["stylelint --fix", "git add"] + "*.{css,scss}": [ + "stylelint --fix", + "git add" + ], + "*.php": [ + "composer fix", + "git add" + ] } } diff --git a/resources/assets/js/checkin.js b/resources/assets/js/checkin.js deleted file mode 100644 index e68fc0c..0000000 --- a/resources/assets/js/checkin.js +++ /dev/null @@ -1,58 +0,0 @@ -function updateTags() { - $('input[name=tags]').val( - $('#tags') - .find('li') - .map(function () { - return $(this).data('value'); - }) - .get() - .join(' ') - ); -} - -function insertTag(value) { - $('
  • | x
  • ') - .data('value', value) - .children(':last-child') - .text(value) - .end() - .appendTo('#tags'); -} - -var initTags = $('input[name=tags]').val(); -if (initTags.trim() !== '') { - initTags.split(' ').forEach(function (value) { - insertTag(value); - }); -} - -$('#tagInput').on('keydown', function (ev) { - var $this = $(this); - if ($this.val().trim() !== '') { - switch (ev.key) { - case 'Tab': - case 'Enter': - case ' ': - if (ev.originalEvent.isComposing !== true) { - insertTag($this.val().trim()); - $this.val(''); - updateTags(); - } - ev.preventDefault(); - break; - } - } else if (ev.key === 'Enter') { - // 誤爆防止 - ev.preventDefault(); - } -}); - -$('#tags') - .on('click', 'li', function (ev) { - $(this).remove(); - updateTags(); - }) - .parent() - .on('click', function (ev) { - $('#tagInput').focus(); - }); \ No newline at end of file diff --git a/resources/assets/js/checkin.ts b/resources/assets/js/checkin.ts new file mode 100644 index 0000000..cb31293 --- /dev/null +++ b/resources/assets/js/checkin.ts @@ -0,0 +1,66 @@ +import Vue from 'vue'; +import TagInput from "./components/TagInput.vue"; +import MetadataPreview from './components/MetadataPreview.vue'; + +export const bus = new Vue({name: "EventBus"}); + +export enum MetadataLoadState { + Inactive, + Loading, + Success, + Failed, +} + +new Vue({ + el: '#app', + data: { + metadata: null, + metadataLoadState: MetadataLoadState.Inactive, + }, + components: { + TagInput, + MetadataPreview + }, + mounted() { + // オカズリンクにURLがセットされている場合は、すぐにメタデータを取得する + const linkInput = this.$el.querySelector("#link"); + if (linkInput && /^https?:\/\//.test(linkInput.value)) { + this.fetchMetadata(linkInput.value); + } + }, + methods: { + // オカズリンクの変更時 + onChangeLink(event: Event) { + if (event.target instanceof HTMLInputElement) { + const url = event.target.value; + + if (url.trim() === '' || !/^https?:\/\//.test(url)) { + this.metadata = null; + this.metadataLoadState = MetadataLoadState.Inactive; + return; + } + + this.fetchMetadata(url); + } + }, + // メタデータの取得 + fetchMetadata(url: string) { + this.metadataLoadState = MetadataLoadState.Loading; + + $.ajax({ + url: '/api/checkin/card', + method: 'get', + type: 'json', + data: { + url + } + }).then(data => { + this.metadata = data; + this.metadataLoadState = MetadataLoadState.Success; + }).catch(e => { + this.metadata = null; + this.metadataLoadState = MetadataLoadState.Failed; + }); + } + } +}); diff --git a/resources/assets/js/components/MetadataPreview.vue b/resources/assets/js/components/MetadataPreview.vue new file mode 100644 index 0000000..eeada94 --- /dev/null +++ b/resources/assets/js/components/MetadataPreview.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/resources/assets/js/components/TagInput.vue b/resources/assets/js/components/TagInput.vue new file mode 100644 index 0000000..6c7908e --- /dev/null +++ b/resources/assets/js/components/TagInput.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/resources/assets/js/vue-shims.d.ts b/resources/assets/js/vue-shims.d.ts new file mode 100644 index 0000000..ad17f79 --- /dev/null +++ b/resources/assets/js/vue-shims.d.ts @@ -0,0 +1,4 @@ +declare module "*.vue" { + import Vue from "vue"; + export default Vue; +} \ No newline at end of file diff --git a/resources/assets/sass/components/_link-card.scss b/resources/assets/sass/components/_link-card.scss index 846fde9..d572ca2 100644 --- a/resources/assets/sass/components/_link-card.scss +++ b/resources/assets/sass/components/_link-card.scss @@ -1,8 +1,18 @@ .link-card { - .row > div:last-child { + .row > div { max-height: 400px; overflow: hidden; + } + .row > div:first-child { + display: flex; + + &:not([display=none]) { + height: 400px; + } + } + + .row > div:last-child { // 省略を表す影を付けるやつ &::before { position: absolute; @@ -11,11 +21,11 @@ width: 100%; height: 100%; content: ''; - background: linear-gradient(transparent 320px, white); + background: linear-gradient(rgba(255, 255, 255, 0) 320px, white); } } .card-text { white-space: pre-line; } -} \ No newline at end of file +} diff --git a/resources/views/components/link-card.blade.php b/resources/views/components/link-card.blade.php index 94c17f9..a10d707 100644 --- a/resources/views/components/link-card.blade.php +++ b/resources/views/components/link-card.blade.php @@ -1,7 +1,7 @@