Merge pull request #93 from shikorism/develop

Release 20190212.2300
This commit is contained in:
shibafu 2019-02-12 23:01:33 +09:00 committed by GitHub
commit e36b9c7c1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 668 additions and 286 deletions

View File

@ -36,6 +36,31 @@ class HomeController extends Controller
$categories = Information::CATEGORIES;
if (Auth::check()) {
// チェックイン動向グラフ用のデータ取得
$groupByDay = Ejaculation::select(DB::raw(
<<<'SQL'
to_char(ejaculated_date, 'YYYY/MM/DD') AS "date",
count(*) AS "count"
SQL
))
->join('users', function ($join) {
$join->on('users.id', '=', 'ejaculations.user_id')
->where('users.accept_analytics', true);
})
->where('ejaculated_date', '>=', now()->subDays(14))
->groupBy(DB::raw("to_char(ejaculated_date, 'YYYY/MM/DD')"))
->orderBy(DB::raw("to_char(ejaculated_date, 'YYYY/MM/DD')"))
->get()
->mapWithKeys(function ($item) {
return [$item['date'] => $item['count']];
});
$globalEjaculationCounts = [];
$day = Carbon::now()->subDays(29);
for ($i = 0; $i < 30; $i++) {
$globalEjaculationCounts[$day->format('Y/m/d') . ' の総チェックイン数'] = $groupByDay[$day->format('Y/m/d')] ?? 0;
$day->addDay();
}
// お惣菜コーナー用のデータ取得
$publicLinkedEjaculations = Ejaculation::join('users', 'users.id', '=', 'ejaculations.user_id')
->where('users.is_protected', false)
@ -47,7 +72,7 @@ class HomeController extends Controller
->take(10)
->get();
return view('home')->with(compact('informations', 'categories', 'publicLinkedEjaculations'));
return view('home')->with(compact('informations', 'categories', 'globalEjaculationCounts', 'publicLinkedEjaculations'));
} else {
return view('guest')->with(compact('informations', 'categories'));
}

View File

@ -21,7 +21,8 @@ class SearchController extends Controller
->where('is_private', false)
->orderBy('ejaculated_date', 'desc')
->with(['user', 'tags'])
->paginate(20);
->paginate(20)
->appends($inputs);
return view('search.index')->with(compact('inputs', 'results'));
}
@ -34,7 +35,8 @@ class SearchController extends Controller
$results = Tag::query()
->where('name', 'like', "%{$inputs['q']}%")
->paginate(50);
->paginate(50)
->appends($inputs);
return view('search.relatedTag')->with(compact('inputs', 'results'));
}

View File

@ -17,9 +17,13 @@ class SettingController extends Controller
{
$inputs = $request->all();
$validator = Validator::make($inputs, [
'display_name' => 'required|string|max:20'
'display_name' => 'required|string|max:20',
'bio' => 'nullable|string|max:160',
'url' => 'nullable|url|max:2000'
], [], [
'display_name' => '名前'
'display_name' => '名前',
'bio' => '自己紹介',
'url' => 'URL'
]);
if ($validator->fails()) {
@ -28,6 +32,8 @@ class SettingController extends Controller
$user = Auth::user();
$user->display_name = $inputs['display_name'];
$user->bio = $inputs['bio'] ?? '';
$user->url = $inputs['url'] ?? '';
$user->save();
return redirect()->route('setting')->with('status', 'プロフィールを更新しました。');

View File

@ -102,7 +102,7 @@ SQL
}
// 月間グラフ用の配列初期化
$month = Carbon::now()->subMonth(11)->firstOfMonth(); // 直近12ヶ月
$month = Carbon::now()->firstOfMonth()->subMonth(11); // 直近12ヶ月
for ($i = 0; $i < 12; $i++) {
$monthlySum[$month->format('Y/m')] = 0;
$month->addMonth();

View File

@ -18,7 +18,7 @@ class RedirectIfAuthenticated
public function handle($request, Closure $next, $guard = null)
{
if (Auth::guard($guard)->check()) {
return redirect('/home');
return redirect()->route('home');
}
return $next($request);

View File

@ -7,7 +7,7 @@ use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class ProfileComposer
class ProfileStatsComposer
{
public function __construct()
{

View File

@ -0,0 +1,80 @@
<?php
namespace App\MetadataResolver;
use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Exception\TransferException;
use Illuminate\Support\Facades\Log;
class ActivityPubResolver implements Resolver, Parser
{
/**
* @var \GuzzleHttp\Client
*/
private $activityClient;
public function __construct()
{
$this->activityClient = new \GuzzleHttp\Client([
'headers' => [
'Accept' => 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
]
]);
}
public function resolve(string $url): Metadata
{
$res = $this->activityClient->get($url);
if ($res->getStatusCode() === 200) {
return $this->parse($res->getBody());
} else {
throw new \RuntimeException("{$res->getStatusCode()}: $url");
}
}
public function parse(string $json): Metadata
{
$activityOrObject = json_decode($json, true);
$object = $activityOrObject['object'] ?? $activityOrObject;
$metadata = new Metadata();
$metadata->title = isset($object['attributedTo']) ? $this->getTitleFromActor($object['attributedTo']) : '';
$metadata->description .= isset($object['summary']) ? $object['summary'] . " | " : '';
$metadata->description .= isset($object['content']) ? $this->html2text($object['content']) : '';
$metadata->image = $object['attachment'][0]['url'] ?? '';
return $metadata;
}
private function getTitleFromActor(string $url): string
{
try {
$res = $this->activityClient->get($url);
if ($res->getStatusCode() !== 200) {
Log::info(self::class . ': Actorの取得に失敗 URL=' . $url);
return '';
}
$actor = json_decode($res->getBody(), true);
$title = $actor['name'] ?? '';
if (isset($actor['preferredUsername'])) {
$title .= ' (@' . $actor['preferredUsername'] . '@' . parse_url($actor['id'], PHP_URL_HOST) . ')';
}
return $title;
} catch (TransferException $e) {
Log::info(self::class . ': Actorの取得に失敗 URL=' . $url);
return '';
}
}
private function html2text(string $html): string
{
$html = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8');
$html = preg_replace('~<br\s*/?\s*>|</p>\s*<p[^>]*>~i', "\n", $html);
$dom = new \DOMDocument();
$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
return $dom->textContent;
}
}

View File

@ -2,6 +2,9 @@
namespace App\MetadataResolver;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ServerException;
class MetadataResolver implements Resolver
{
public $rules = [
@ -15,10 +18,20 @@ class MetadataResolver implements Resolver
'~www\.pixiv\.net/member_illust\.php\?illust_id=\d+~' => PixivResolver::class,
'~fantia\.jp/posts/\d+~' => FantiaResolver::class,
'~dmm\.co\.jp/~' => FanzaResolver::class,
'~www\.patreon\.com/~' => PatreonResolver::class,
'~www\.deviantart\.com/.*/art/.*~' => DeviantArtResolver::class,
'/.*/' => OGPResolver::class
'~\.syosetu\.com/n\d+[a-z]{2,}~' => NarouResolver::class,
];
public $mimeTypes = [
'application/activity+json' => ActivityPubResolver::class,
'application/ld+json' => ActivityPubResolver::class,
'text/html' => OGPResolver::class,
'*/*' => OGPResolver::class
];
public $defaultResolver = OGPResolver::class;
public function resolve(string $url): Metadata
{
foreach ($this->rules as $pattern => $class) {
@ -29,6 +42,64 @@ class MetadataResolver implements Resolver
}
}
$result = $this->resolveWithAcceptHeader($url);
if ($result !== null) {
return $result;
}
if (isset($this->defaultResolver)) {
$resolver = new $this->defaultResolver();
return $resolver->resolve($url);
}
throw new \UnexpectedValueException('URL not matched.');
}
public function resolveWithAcceptHeader(string $url): ?Metadata
{
try {
// Rails等はAcceptに */* が入っていると、ブラウザの適当なAcceptヘッダだと判断して全部無視してしまう。
// c.f. https://github.com/rails/rails/issues/9940
// そこでここでは */* を「Acceptヘッダを無視してきたレスポンスよくある」のハンドラとして扱い、
// Acceptヘッダには */* を足さないことにする。
$acceptTypes = array_diff(array_keys($this->mimeTypes), ['*/*']);
$client = new \GuzzleHttp\Client();
$res = $client->request('GET', $url, [
'headers' => [
'Accept' => implode(', ', $acceptTypes)
]
]);
if ($res->getStatusCode() === 200) {
preg_match('/^[^;\s]+/', $res->getHeaderLine('Content-Type'), $matches);
$mimeType = $matches[0];
if (isset($this->mimeTypes[$mimeType])) {
$class = $this->mimeTypes[$mimeType];
$parser = new $class();
return $parser->parse($res->getBody());
}
if (isset($this->mimeTypes['*/*'])) {
$class = $this->mimeTypes['*/*'];
$parser = new $class();
return $parser->parse($res->getBody());
}
} else {
// code < 400 && code !== 200 => fallback
}
} catch (ClientException $e) {
// 406 Not Acceptable は多分Acceptが原因なので無視してフォールバック
if ($e->getResponse()->getStatusCode() !== 406) {
throw $e;
}
} catch (ServerException $e) {
// 5xx は変なAcceptが原因かもしれないので無視してフォールバック
}
return null;
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\MetadataResolver;
use GuzzleHttp\Cookie\CookieJar;
class NarouResolver implements Resolver
{
public function resolve(string $url): Metadata
{
$cookieJar = CookieJar::fromArray(['over18' => 'yes'], '.syosetu.com');
$client = new \GuzzleHttp\Client();
$res = $client->get($url, ['cookies' => $cookieJar]);
if ($res->getStatusCode() === 200) {
$ogpResolver = new OGPResolver();
$metadata = $ogpResolver->parse($res->getBody());
$metadata->description = '';
$dom = new \DOMDocument();
@$dom->loadHTML(mb_convert_encoding($res->getBody(), 'HTML-ENTITIES', 'ASCII,JIS,UTF-8,eucJP-win,SJIS-win'));
$xpath = new \DOMXPath($dom);
$description = [];
// 作者名
$writerNodes = $xpath->query('//*[contains(@class, "novel_writername")]');
if ($writerNodes->length !== 0 && !empty($writerNodes->item(0)->textContent)) {
$description[] = trim($writerNodes->item(0)->textContent);
}
// あらすじ
$exNodes = $xpath->query('//*[@id="novel_ex"]');
if ($exNodes->length !== 0 && !empty($exNodes->item(0)->textContent)) {
$summary = trim($exNodes->item(0)->textContent);
$description[] = mb_strimwidth($summary, 0, 101, '…'); // 100 + '…'(1)
}
$metadata->description = implode(' / ', $description);
return $metadata;
} else {
throw new \RuntimeException("{$res->getStatusCode()}: $url");
}
}
}

View File

@ -2,7 +2,7 @@
namespace App\MetadataResolver;
class OGPResolver implements Resolver
class OGPResolver implements Resolver, Parser
{
public function resolve(string $url): Metadata
{
@ -30,7 +30,7 @@ class OGPResolver implements Resolver
$metadata->title = $nodes->item(0)->textContent;
}
}
$metadata->description = $this->findContent($xpath, '//meta[@*="og:description"]', '//meta[@*="twitter:description"]');
$metadata->description = $this->findContent($xpath, '//meta[@*="og:description"]', '//meta[@*="twitter:description"]', '//meta[@name="description"]');
$metadata->image = $this->findContent($xpath, '//meta[@*="og:image"]', '//meta[@*="twitter:image"]');
return $metadata;

View File

@ -0,0 +1,8 @@
<?php
namespace App\MetadataResolver;
interface Parser
{
public function parse(string $body): Metadata;
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\MetadataResolver;
use Carbon\Carbon;
class PatreonResolver implements Resolver
{
public function resolve(string $url): Metadata
{
$client = new \GuzzleHttp\Client();
$res = $client->get($url);
if ($res->getStatusCode() === 200) {
$ogpResolver = new OGPResolver();
$metadata = $ogpResolver->parse($res->getBody());
parse_str(parse_url($metadata->image, PHP_URL_QUERY), $temp);
$expires_at_unixtime = $temp["token-time"];
$expires_at = Carbon::createFromTimestamp($expires_at_unixtime);
$metadata->expires_at = $expires_at;
return $metadata;
} else {
throw new \RuntimeException("{$res->getStatusCode()}: $url");
}
}
}

View File

@ -8,6 +8,7 @@ class PixivResolver implements Resolver
* サムネイル画像 URL から最大長辺 1200px の画像 URL に変換する
*
* @param string $thumbnailUrl サムネイル画像 URL
*
* @return string 1200px の画像 URL
*/
public function thumbnailToMasterUrl(string $thumbnailUrl): string
@ -23,6 +24,7 @@ class PixivResolver implements Resolver
* HUGE THANKS TO PIXIV.CAT!
*
* @param string $pixivUrl i.pximg URL
*
* @return string i.pixiv.cat URL
*/
public function proxize(string $pixivUrl): string
@ -32,62 +34,33 @@ class PixivResolver implements Resolver
public function resolve(string $url): Metadata
{
preg_match("~illust_id=(\d+)~", parse_url($url)['query'], $match);
$illustId = $match[1];
parse_str(parse_url($url, PHP_URL_QUERY), $params);
$illustId = $params['illust_id'];
// 漫画ページかつページ数あり
if (strpos(parse_url($url)['query'], 'mode=manga_big') && strpos(parse_url($url)['query'], 'page=')) {
preg_match("~page=(\d+)~", parse_url($url)['query'], $match);
$page = $match[1];
// 漫画ページページ数はmanga_bigならあるかも
if ($params['mode'] === 'manga_big' || $params['mode'] === 'manga') {
$page = $params['page'] ?? 0;
// 未ログインでは漫画ページを開けないため、URL を作品ページに変換する
$url = str_replace('mode=manga_big', 'mode=medium', $url);
$url = preg_replace('~mode=manga(_big)?~', 'mode=medium', $url);
}
$client = new \GuzzleHttp\Client();
$res = $client->get($url);
if ($res->getStatusCode() === 200) {
$ogpResolver = new OGPResolver();
$metadata = $ogpResolver->parse($res->getBody());
$client = new \GuzzleHttp\Client();
$res = $client->get($url);
if ($res->getStatusCode() === 200) {
$ogpResolver = new OGPResolver();
$metadata = $ogpResolver->parse($res->getBody());
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];
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];
$illustUrl = $this->thumbnailToMasterUrl($illustThumbnailUrl);
$illustUrl = $this->thumbnailToMasterUrl($illustThumbnailUrl);
// 指定ページに変換
$illustUrl = str_replace('p0_master', "p{$page}_master", $illustUrl);
$metadata->image = $this->proxize($illustUrl);
$metadata->image = $this->proxize($illustUrl);
return $metadata;
} else {
throw new \RuntimeException("{$res->getStatusCode()}: $url");
}
return $metadata;
} else {
$client = new \GuzzleHttp\Client();
$res = $client->get($url);
if ($res->getStatusCode() === 200) {
$ogpResolver = new OGPResolver();
$metadata = $ogpResolver->parse($res->getBody());
// OGP がデフォルト画像であるようならなんとかして画像を取得する
if (strpos($metadata->image, 'pixiv_logo.gif') || strpos($metadata->image, 'pictures.jpg')) {
// 作品ページの場合のみ対応
if (strpos(parse_url($url)['query'], 'mode=medium')) {
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];
$illustUrl = $this->thumbnailToMasterUrl($illustThumbnailUrl);
$metadata->image = $this->proxize($illustUrl);
}
}
return $metadata;
} else {
throw new \RuntimeException("{$res->getStatusCode()}: $url");
}
throw new \RuntimeException("{$res->getStatusCode()}: $url");
}
}
}

View File

@ -2,7 +2,7 @@
namespace App\Providers;
use App\Http\ViewComposers\ProfileComposer;
use App\Http\ViewComposers\ProfileStatsComposer;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
@ -15,7 +15,7 @@ class ViewComposerServiceProvider extends ServiceProvider
*/
public function boot()
{
View::composer('components.profile', ProfileComposer::class);
View::composer('components.profile-stats', ProfileStatsComposer::class);
}
/**

View File

@ -5,7 +5,7 @@
"license": "MIT",
"type": "project",
"require": {
"php": ">=7.0.0",
"php": ">=7.1.0",
"anhskohbo/no-captcha": "^3.0",
"doctrine/dbal": "^2.9",
"fideloper/proxy": "~3.3",

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddBioAndUrlToUsers extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->string('bio', 160)->default('');
$table->text('url')->default('');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('bio');
$table->dropColumn('url');
});
}
}

View File

@ -1,5 +1,4 @@
; Dockerでのデバッグ用設定
zend_extension=xdebug.so
xdebug.remote_enable=true
xdebug.remote_autostart=true
xdebug.remote_host=host.docker.internal

30
public/css/tissue.css vendored
View File

@ -15,6 +15,25 @@
overflow-x: auto;
}
.tis-need-agecheck .container {
filter: blur(45px);
pointer-events: none;
}
.container {
transition: filter .15s liner;
}
.list-group-item.no-side-border {
border-left: none;
border-right: none;
border-radius: 0;
}
.list-group-item.border-bottom-only:first-child {
border-top: none;
}
.list-group-item.border-bottom-only {
border-left: none;
border-right: none;
@ -27,4 +46,15 @@
.timeline-action-item {
margin-left: 16px;
}
.tis-global-count-graph {
height: 90px;
border-bottom: 1px solid rgba(0, 0, 0, .125);
}
@media (min-width: 992px) {
.tis-sidebar-info {
font-size: small;
}
}

49
public/js/tissue.js vendored Normal file
View File

@ -0,0 +1,49 @@
// app.jsの名はモジュールバンドラーを投入する日まで予約しておく。CSSも同じ。
(function ($) {
$.fn.linkCard = function (options) {
var settings = $.extend({
endpoint: '/api/checkin/card'
}, options);
return this.each(function () {
var $this = $(this);
$.ajax({
url: settings.endpoint,
method: 'get',
type: 'json',
data: {
url: $this.find('a').attr('href')
}
}).then(function (data) {
var $title = $this.find('.card-title');
var $desc = $this.find('.card-text');
var $image = $this.find('img');
if (data.title === '') {
$title.hide();
} else {
$title.text(data.title);
}
if (data.description === '') {
$desc.hide();
} else {
$desc.text(data.description);
}
if (data.image === '') {
$image.hide();
} else {
$image.attr('src', data.image);
}
if (data.title !== '' || data.description !== '' || data.image !== '') {
$this.removeClass('d-none');
}
});
});
};
})(jQuery);

View File

@ -12,6 +12,10 @@
<div class="container">
<h2>新規登録</h2>
<hr>
<div class="alert alert-warning">
<p class="mb-0"><strong>注意!</strong> Tissueでは、登録に使用したメールアドレスの <a href="https://ja.gravatar.com/" rel="noreferrer">Gravatar</a> を使用します。</p>
<p class="mb-0">他の場所での活動と紐付いてほしくない場合、使用予定のメールアドレスにGravatarが設定されていないかを確認することを推奨します。</p>
</div>
<div class="row justify-content-center my-5">
<div class="col-lg-6">
<form method="post" action="{{ route('register') }}">

View File

@ -0,0 +1,15 @@
<h6 class="font-weight-bold"><span class="oi oi-timer"></span> 現在のセッション</h6>
@if (isset($currentSession))
<p class="card-text mb-0">{{ $currentSession }}経過</p>
<p class="card-text">({{ $latestEjaculation->ejaculated_date->format('Y/m/d H:i') }} にリセット)</p>
@else
<p class="card-text mb-0">計測がまだ始まっていません</p>
<p class="card-text">(一度チェックインすると始まります)</p>
@endif
<h6 class="font-weight-bold"><span class="oi oi-graph"></span> 概況</h6>
<p class="card-text mb-0">平均記録: {{ Formatter::formatInterval($summary[0]->average) }}</p>
<p class="card-text mb-0">最長記録: {{ Formatter::formatInterval($summary[0]->longest) }}</p>
<p class="card-text mb-0">最短記録: {{ Formatter::formatInterval($summary[0]->shortest) }}</p>
<p class="card-text mb-0">合計時間: {{ Formatter::formatInterval($summary[0]->total_times) }}</p>
<p class="card-text">通算回数: {{ $summary[0]->total_checkins }}</p>

View File

@ -1,6 +1,6 @@
<div class="card mb-4">
<div class="card-body">
<img src="{{ $user->getProfileImageUrl(64) }}" class="rounded mb-1">
<img src="{{ $user->getProfileImageUrl(128) }}" class="rounded mb-1">
<h4 class="card-title">
<a class="text-dark" href="{{ route('user.profile', ['name' => $user->name]) }}">{{ $user->display_name }}</a>
</h4>
@ -11,22 +11,28 @@
@endif
</h6>
@if (!$user->is_protected || $user->isMe())
<h6 class="font-weight-bold mt-4"><span class="oi oi-timer"></span> 現在のセッション</h6>
@if (isset($currentSession))
<p class="card-text mb-0">{{ $currentSession }}経過</p>
<p class="card-text">({{ $latestEjaculation->ejaculated_date->format('Y/m/d H:i') }} にリセット)</p>
@else
<p class="card-text mb-0">計測がまだ始まっていません</p>
<p class="card-text">(一度チェックインすると始まります)</p>
@endif
{{-- Bio --}}
@if (!empty($user->bio))
<p class="card-text mt-3 mb-0">
{!! Formatter::linkify(nl2br(e($user->bio))) !!}
</p>
@endif
<h6 class="font-weight-bold"><span class="oi oi-graph"></span> 概況</h6>
<p class="card-text mb-0">平均記録: {{ Formatter::formatInterval($summary[0]->average) }}</p>
<p class="card-text mb-0">最長記録: {{ Formatter::formatInterval($summary[0]->longest) }}</p>
<p class="card-text mb-0">最短記録: {{ Formatter::formatInterval($summary[0]->shortest) }}</p>
<p class="card-text mb-0">合計時間: {{ Formatter::formatInterval($summary[0]->total_times) }}</p>
<p class="card-text">通算回数: {{ $summary[0]->total_checkins }}</p>
{{-- URL --}}
@if (!empty($user->url))
<p class="card-text d-flex mt-3">
<span class="oi oi-link-intact mr-1 mt-1"></span>
<a href="{{ $user->url }}" rel="me nofollow noopener" target="_blank" class="text-truncate">{{ preg_replace('~\Ahttps?://~', '', $user->url) }}</a>
</p>
@endif
</div>
</div>
</div>
@if (!$user->is_protected || $user->isMe())
<div class="card mb-4">
<div class="card-body">
@component('components.profile-stats', ['user' => $user])
@endcomponent
</div>
</div>
@endif

View File

@ -40,7 +40,7 @@
<div class="form-group col-sm-12">
<input name="tags" type="hidden" value="{{ old('tags') ?? $defaults['tags'] }}">
<label for="tagInput"><span class="oi oi-tags"></span> タグ</label>
<div class="form-control {{ $errors->has('tags') ? ' is-invalid' : '' }}">
<div class="form-control h-auto {{ $errors->has('tags') ? ' is-invalid' : '' }}">
<ul id="tags" class="list-inline d-inline"></ul>
<input id="tagInput" type="text" style="outline: 0; border: 0;">
</div>

View File

@ -41,7 +41,7 @@
<div class="form-group col-sm-12">
<input name="tags" type="hidden" value="{{ old('tags') ?? $ejaculation->textTags() }}">
<label for="tagInput"><span class="oi oi-tags"></span> タグ</label>
<div class="form-control {{ $errors->has('tags') ? ' is-invalid' : '' }}">
<div class="form-control h-auto {{ $errors->has('tags') ? ' is-invalid' : '' }}">
<ul id="tags" class="list-inline d-inline"></ul>
<input id="tagInput" type="text" style="outline: 0; border: 0;">
</div>

View File

@ -112,42 +112,8 @@
form.submit();
});
$('.link-card').each(function () {
var $this = $(this);
$.ajax({
url: '{{ url('/api/checkin/card') }}',
method: 'get',
type: 'json',
data: {
url: $this.find('a').attr('href')
}
}).then(function (data) {
var $title = $this.find('.card-title');
var $desc = $this.find('.card-text');
var $image = $this.find('img');
if (data.title === '') {
$title.hide();
} else {
$title.text(data.title);
}
if (data.description === '') {
$desc.hide();
} else {
$desc.text(data.description);
}
if (data.image === '') {
$image.hide();
} else {
$image.attr('src', data.image);
}
if (data.title !== '' || data.description !== '' || data.image !== '') {
$this.removeClass('d-none');
}
});
$('.link-card').linkCard({
endpoint: '{{ url('/api/checkin/card') }}'
});
</script>
@endpush

View File

@ -31,6 +31,9 @@
<div class="list-group list-group-flush">
@foreach($informations as $info)
<a class="list-group-item" href="{{ route('info.show', ['id' => $info->id]) }}">
@if ($info->pinned)
<span class="badge badge-secondary"><span class="oi oi-pin"></span>ピン留め</span>
@endif
<span class="badge {{ $categories[$info->category]['class'] }}">{{ $categories[$info->category]['label'] }}</span> {{ $info->title }} <small class="text-secondary">- {{ $info->created_at->format('n月j日') }}</small>
</a>
@endforeach

View File

@ -7,119 +7,142 @@
<div class="container">
<div class="row">
<div class="col-lg-4">
@component('components.profile', ['user' => Auth::user()])
@endcomponent
</div>
<div class="col-lg-8">
<div class="card mb-4">
<div class="card-body">
<div class="d-flex flex-row align-items-end mb-4">
<img src="{{ Auth::user()->getProfileImageUrl(48) }}" class="rounded mr-2">
<div class="d-flex flex-column overflow-hidden">
<h5 class="card-title text-truncate">
<a class="text-dark" href="{{ route('user.profile', ['name' => Auth::user()->name]) }}">{{ Auth::user()->display_name }}</a>
</h5>
<h6 class="card-subtitle">
<a class="text-muted" href="{{ route('user.profile', ['name' => Auth::user()->name]) }}">&commat;{{ Auth::user()->name }}</a>
@if (Auth::user()->is_protected)
<span class="oi oi-lock-locked text-muted"></span>
@endif
</h6>
</div>
</div>
@component('components.profile-stats', ['user' => Auth::user()])
@endcomponent
</div>
</div>
<div class="card mb-4">
<div class="card-header">サイトからのお知らせ</div>
<div class="list-group list-group-flush">
<div class="list-group list-group-flush tis-sidebar-info">
@foreach($informations as $info)
<a class="list-group-item" href="{{ route('info.show', ['id' => $info->id]) }}">
@if ($info->pinned)
<span class="badge badge-secondary"><span class="oi oi-pin"></span>ピン留め</span>
@endif
<span class="badge {{ $categories[$info->category]['class'] }}">{{ $categories[$info->category]['label'] }}</span> {{ $info->title }} <small class="text-secondary">- {{ $info->created_at->format('n月j日') }}</small>
</a>
@endforeach
<a href="{{ route('info') }}" class="list-group-item text-right">お知らせ一覧 &raquo;</a>
</div>
</div>
@if (!empty($publicLinkedEjaculations))
<div class="card mb-4">
<div class="card-header">お惣菜コーナー</div>
<div class="card-body">
<p class="card-text">最近の公開チェックインから、オカズリンク付きのものを表示しています。</p>
</div>
<ul class="list-group list-group-flush">
@foreach ($publicLinkedEjaculations as $ejaculation)
<li class="list-group-item pt-3 pb-3">
<!-- span -->
<div class="d-flex justify-content-between">
<h5>
<a href="{{ route('user.profile', ['id' => $ejaculation->user->name]) }}" class="text-dark"><img src="{{ $ejaculation->user->getProfileImageUrl(30) }}" width="30" height="30" class="rounded d-inline-block align-bottom"> {{ $ejaculation->user->display_name }}</a>
<a href="{{ route('checkin.show', ['id' => $ejaculation->id]) }}" class="text-muted"><small>{{ $ejaculation->ejaculated_date->format('Y/m/d H:i') }}</small></a>
</h5>
<div>
<a class="text-secondary timeline-action-item" href="{{ route('checkin', ['link' => $ejaculation->link, 'tags' => $ejaculation->textTags()]) }}"><span class="oi oi-reload" data-toggle="tooltip" data-placement="bottom" title="同じオカズでチェックイン"></span></a>
</div>
</div>
<!-- tags -->
@if ($ejaculation->tags->isNotEmpty())
<p class="mb-2">
@foreach ($ejaculation->tags as $tag)
<a class="badge badge-secondary" href="{{ route('search', ['q' => $tag->name]) }}"><span class="oi oi-tag"></span> {{ $tag->name }}</a>
@endforeach
</p>
@endif
<!-- okazu link -->
@if (!empty($ejaculation->link))
<div class="row mx-0">
<div class="card link-card mb-2 px-0 col-12 col-md-6 d-none" style="font-size: small;">
<a class="text-dark card-link" href="{{ $ejaculation->link }}" target="_blank" rel="noopener">
<img src="" alt="Thumbnail" class="card-img-top bg-secondary">
<div class="card-body">
<h6 class="card-title font-weight-bold">タイトル</h6>
<p class="card-text">コンテンツの説明文</p>
</div>
</a>
</div>
<p class="d-flex align-items-baseline mb-2 col-12 px-0">
<span class="oi oi-link-intact mr-1"></span><a class="overflow-hidden" href="{{ $ejaculation->link }}" target="_blank" rel="noopener">{{ $ejaculation->link }}</a>
</p>
</div>
@endif
<!-- note -->
@if (!empty($ejaculation->note))
<p class="mb-0 tis-word-wrap">
{!! Formatter::linkify(nl2br(e($ejaculation->note))) !!}
</p>
@endif
</li>
@endforeach
</ul>
</div>
<div class="col-lg-8">
@if (!empty($globalEjaculationCounts))
<h5>チェックインの動向</h5>
<div class="w-100 mb-4 position-relative tis-global-count-graph">
<canvas id="global-count-graph"></canvas>
</div>
@endif
@if (!empty($publicLinkedEjaculations))
<h5 class="mb-3">お惣菜コーナー</h5>
<p class="text-secondary">最近の公開チェックインから、オカズリンク付きのものを表示しています。</p>
<ul class="list-group">
@foreach ($publicLinkedEjaculations as $ejaculation)
<li class="list-group-item no-side-border pt-3 pb-3 tis-word-wrap">
<!-- span -->
<div class="d-flex justify-content-between">
<h5>
<a href="{{ route('user.profile', ['id' => $ejaculation->user->name]) }}" class="text-dark"><img src="{{ $ejaculation->user->getProfileImageUrl(30) }}" width="30" height="30" class="rounded d-inline-block align-bottom"> {{ $ejaculation->user->display_name }}</a>
<a href="{{ route('checkin.show', ['id' => $ejaculation->id]) }}" class="text-muted"><small>{{ $ejaculation->ejaculated_date->format('Y/m/d H:i') }}</small></a>
</h5>
<div>
<a class="text-secondary timeline-action-item" href="{{ route('checkin', ['link' => $ejaculation->link, 'tags' => $ejaculation->textTags()]) }}"><span class="oi oi-reload" data-toggle="tooltip" data-placement="bottom" title="同じオカズでチェックイン"></span></a>
</div>
</div>
<!-- tags -->
@if ($ejaculation->tags->isNotEmpty())
<p class="mb-2">
@foreach ($ejaculation->tags as $tag)
<a class="badge badge-secondary" href="{{ route('search', ['q' => $tag->name]) }}"><span class="oi oi-tag"></span> {{ $tag->name }}</a>
@endforeach
</p>
@endif
<!-- okazu link -->
@if (!empty($ejaculation->link))
<div class="row mx-0">
<div class="card link-card mb-2 px-0 col-12 col-md-6 d-none" style="font-size: small;">
<a class="text-dark card-link" href="{{ $ejaculation->link }}" target="_blank" rel="noopener">
<img src="" alt="Thumbnail" class="card-img-top bg-secondary">
<div class="card-body">
<h6 class="card-title font-weight-bold">タイトル</h6>
<p class="card-text">コンテンツの説明文</p>
</div>
</a>
</div>
<p class="d-flex align-items-baseline mb-2 col-12 px-0">
<span class="oi oi-link-intact mr-1"></span><a class="overflow-hidden" href="{{ $ejaculation->link }}" target="_blank" rel="noopener">{{ $ejaculation->link }}</a>
</p>
</div>
@endif
<!-- note -->
@if (!empty($ejaculation->note))
<p class="mb-0 tis-word-wrap">
{!! Formatter::linkify(nl2br(e($ejaculation->note))) !!}
</p>
@endif
</li>
@endforeach
</ul>
@endif
</div>
</div>
</div>
@endsection
@push('script')
<script type="text/javascript" src="//cdn.jsdelivr.net/npm/chart.js@2.7.1/dist/Chart.min.js"></script>
<script>
$('.link-card').each(function () {
var $this = $(this);
$.ajax({
url: '{{ url('/api/checkin/card') }}',
method: 'get',
type: 'json',
data: {
url: $this.find('a').attr('href')
}
}).then(function (data) {
var $title = $this.find('.card-title');
var $desc = $this.find('.card-text');
var $image = $this.find('img');
$('.link-card').linkCard({
endpoint: '{{ url('/api/checkin/card') }}'
});
if (data.title === '') {
$title.hide();
} else {
$title.text(data.title);
new Chart(document.getElementById('global-count-graph').getContext('2d'), {
type: 'bar',
data: {
labels: @json(array_keys($globalEjaculationCounts)),
datasets: [{
data: @json(array_values($globalEjaculationCounts)),
backgroundColor: 'rgba(0, 0, 0, .1)',
borderColor: 'rgba(0, 0, 0, .25)',
borderWidth: 1
}]
},
options: {
maintainAspectRatio: false,
legend: {
display: false
},
elements: {
line: {}
},
scales: {
xAxes: [{
display: false
}],
yAxes: [{
display: false,
ticks: {
beginAtZero: true
}
}]
}
if (data.description === '') {
$desc.hide();
} else {
$desc.text(data.description);
}
if (data.image === '') {
$image.hide();
} else {
$image.attr('src', data.image);
}
if (data.title !== '' || data.description !== '' || data.image !== '') {
$this.removeClass('d-none');
}
});
}
});
</script>
@endpush

View File

@ -9,6 +9,9 @@
<div class="list-group">
@foreach($informations as $info)
<a class="list-group-item border-bottom-only pt-3 pb-3" href="{{ route('info.show', ['id' => $info->id]) }}">
@if ($info->pinned)
<span class="badge badge-secondary"><span class="oi oi-pin"></span>ピン留め</span>
@endif
<span class="badge {{ $categories[$info->category]['class'] }}">{{ $categories[$info->category]['label'] }}</span> {{ $info->title }} <small class="text-secondary">- {{ $info->created_at->format('n月j日') }}</small>
</a>
@endforeach

View File

@ -11,7 +11,12 @@
</ol>
</nav>
<h2><span class="badge {{ $category['class'] }}">{{ $category['label'] }}</span> {{ $info->title }}</h2>
<p class="text-secondary"><span class="oi oi-calendar"></span> {{ $info->created_at->format('Y年n月j日') }}</p>
<p class="text-secondary">
@if ($info->pinned)
<span class="badge badge-secondary"><span class="oi oi-pin"></span>ピン留め</span>
@endif
<span class="oi oi-calendar"></span> {{ $info->created_at->format('Y年n月j日') }}
</p>
@parsedown($info->content)
</div>
@endsection

View File

@ -18,7 +18,18 @@
@stack('head')
</head>
<body>
<body class="{{Auth::check() ? '' : 'tis-need-agecheck'}}">
<noscript class="navbar navbar-light bg-warning">
<div class="container-fluid">
<div class="d-flex flex-column mx-auto">
<p class="m-0 text-dark">Tissueを利用するには、ブラウザのJavaScriptとCookieを有効にする必要があります。</p>
<p class="m-0 text-info">
<a href="https://www.enable-javascript.com/ja/" target="_blank" rel="nofollow noopener">ブラウザでJavaScriptを有効にする方法</a>
<a href="https://www.whatismybrowser.com/guides/how-to-enable-cookies/auto" target="_blank" rel="nofollow noopener">ブラウザでCookieを有効にする方法</a>
</p>
</div>
</div>
</noscript>
<nav class="navbar navbar-expand-lg navbar-light bg-light {{ !Auth::check() && Route::currentRouteName() === 'home' ? '' : 'mb-4'}}">
<form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;">
{{ csrf_field() }}
@ -104,19 +115,49 @@
@yield('content')
<footer class="tis-footer mt-4">
<div class="container p-3 p-md-4">
<p>Copyright (c) 2017 shikorism.net</p>
<p>Copyright (c) 2017-2019 shikorism.net</p>
<ul class="list-inline">
<li class="list-inline-item"><a href="https://github.com/shibafu528" class="text-dark">Admin(@shibafu528)</a></li>
<li class="list-inline-item"><a href="https://github.com/shikorism/tissue" class="text-dark">GitHub</a></li>
</ul>
</div>
</footer>
@guest
<div class="modal fade" id="ageCheckModal" tabindex="-1" role="dialog" aria-labelledby="ageCheckModalTitle" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ageCheckModalTitle">Tissue へようこそ!</h5>
</div>
<div class="modal-body">
この先のコンテンツには暴力表現や性描写など、18歳未満の方が閲覧できないコンテンツが含まれています。
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-dismiss="modal">まかせて</button>
<a href="https://cookpad.com" rel="noreferrer" class="btn btn-secondary">ごめん無理</a>
</div>
</div>
</div>
</div>
@endguest
<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.11.0/umd/popper.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-cookie/2.2.0/js.cookie.js"></script>
<script type="text/javascript" src="{{ asset('js/bootstrap.min.js') }}"></script>
<script type="text/javascript" src="{{ asset('js/tissue.js') }}"></script>
<script>
$(function(){
@guest
if (Cookies.get('agechecked')) {
$('body').removeClass('tis-need-agecheck');
} else {
$('#ageCheckModal').modal({ backdrop: 'static' })
.on('hide.bs.modal', function() {
$('body').removeClass('tis-need-agecheck');
Cookies.set('agechecked', '1', { expires: 365 });
});
}
@endguest
$('[data-toggle="tooltip"]').tooltip();
$('.alert').alert();
@if (session('status'))

View File

@ -80,42 +80,8 @@
@push('script')
<script>
$('.link-card').each(function () {
var $this = $(this);
$.ajax({
url: '{{ url('/api/checkin/card') }}',
method: 'get',
type: 'json',
data: {
url: $this.find('a').attr('href')
}
}).then(function (data) {
var $title = $this.find('.card-title');
var $desc = $this.find('.card-text');
var $image = $this.find('img');
if (data.title === '') {
$title.hide();
} else {
$title.text(data.title);
}
if (data.description === '') {
$desc.hide();
} else {
$desc.text(data.description);
}
if (data.image === '') {
$image.hide();
} else {
$image.attr('src', data.image);
}
if (data.title !== '' || data.description !== '' || data.image !== '') {
$this.removeClass('d-none');
}
});
$('.link-card').linkCard({
endpoint: '{{ url('/api/checkin/card') }}'
});
</script>
@endpush

View File

@ -16,7 +16,7 @@
<div class="invalid-feedback">{{ $errors->first('display_name') }}</div>
@endif
</div>
<div class="from-group mt-2">
<div class="from-group mt-3">
<label for="name">ユーザー名</label>
<div class="input-group">
<div class="input-group-prepend">
@ -26,6 +26,24 @@
</div>
<small class="form-text text-muted">現在は変更できません。</small>
</div>
<div class="form-group mt-3">
<label for="bio">自己紹介</label>
<textarea id="bio" name="bio" rows="3" class="form-control {{ $errors->has('bio') ? ' is-invalid' : '' }}">{{ old('bio') ?? Auth::user()->bio }}</textarea>
<small class="form-text text-muted">最大 160 文字</small>
@if ($errors->has('bio'))
<div class="invalid-feedback">{{ $errors->first('bio') }}</div>
@endif
</div>
<div class="form-group mt-3">
<label for="url">URL</label>
<input id="url" name="url" type="url" class="form-control {{ $errors->has('url') ? ' is-invalid' : '' }}"
value="{{ old('url') ?? Auth::user()->url }}" autocomplete="off">
@if ($errors->has('url'))
<div class="invalid-feedback">{{ $errors->first('url') }}</div>
@endif
</div>
<button type="submit" class="btn btn-primary mt-4">更新</button>
</form>

View File

@ -136,42 +136,8 @@
form.submit();
});
$('.link-card').each(function () {
var $this = $(this);
$.ajax({
url: '{{ url('/api/checkin/card') }}',
method: 'get',
type: 'json',
data: {
url: $this.find('a').attr('href')
}
}).then(function (data) {
var $title = $this.find('.card-title');
var $desc = $this.find('.card-text');
var $image = $this.find('img');
if (data.title === '') {
$title.hide();
} else {
$title.text(data.title);
}
if (data.description === '') {
$desc.hide();
} else {
$desc.text(data.description);
}
if (data.image === '') {
$image.hide();
} else {
$image.attr('src', data.image);
}
if (data.title !== '' || data.description !== '' || data.image !== '') {
$this.removeClass('d-none');
}
});
$('.link-card').linkCard({
endpoint: '{{ url('/api/checkin/card') }}'
});
</script>
@endpush

View File

@ -35,4 +35,19 @@ class OGPResolverTest extends TestCase
$this->assertEmpty($metadata->description);
$this->assertEmpty($metadata->image);
}
public function testResolveTitleAndDescription()
{
$resolver = new OGPResolver();
$html = <<<EOF
<title>Welcome to my homepage</title>
<meta name="description" content="This is my super hyper ultra homepage!!" />
EOF;
$metadata = $resolver->parse($html);
$this->assertEquals('Welcome to my homepage', $metadata->title);
$this->assertEquals('This is my super hyper ultra homepage!!', $metadata->description);
$this->assertEmpty($metadata->image);
}
}