211110 메인화면 개발중

1. 메인화면에 필요한 게시물 리스트 조회 로직과 화면 렌더링 구현
2. 조회수순으로 메인화면 노출과 최신 업로드 순 게시물 노출 로직 구분
3. 무한스크롤 구현
4. 스크롤 화살표 구현
5. 계층형 카테고리 개발과 화면 렌더링 완료
   - 롤업함수와 백트래킹으로 구현
This commit is contained in:
jinia91
2021-11-10 19:09:14 +09:00
parent f35a9dab0d
commit 8e0dc43370
58 changed files with 3443 additions and 490 deletions

View File

@@ -30,6 +30,11 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation group: 'com.google.code.gson', name: 'gson', version: '2.8.9'
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.7.1'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.0'
implementation 'com.querydsl:querydsl-jpa'
implementation 'com.github.node-gradle:gradle-node-plugin:3.1.0'
@@ -44,6 +49,7 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:2.2.0'
}
test {

19
node_modules/@yaireo/tagify/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,19 @@
Copyright 2019 Yair Even-Or
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

995
node_modules/@yaireo/tagify/README.md generated vendored Normal file
View File

@@ -0,0 +1,995 @@
<h1 align="center">
<a href='https://yaireo.github.io/tagify'><img src="/docs/readme-header.svg" width="320" height="160"><a/>
<br><br>
<a href='https://yaireo.github.io/tagify'>Tagify</a> - <em>tags</em> input component
</h1>
<p align="center">
Transforms an input field or a textarea into a <em>Tags component</em>, in an easy, customizable way,
with great performance and small code footprint, exploded with features.
<br>
<strong>Vanilla</strong><strong>React</strong><strong>Vue</strong><strong>Angular</strong>
<p>
<h3 align="center">
👉 <a href="https://yaireo.github.io/tagify">See Demos</a> 👈
</h3>
<p align="center">
<a href='https://www.npmjs.com/package/@yaireo/tagify'>
<img src="https://img.shields.io/npm/v/@yaireo/tagify.svg" />
</a>
<a href='https://simple.wikipedia.org/wiki/MIT_License'>
<img src="https://img.shields.io/badge/license-MIT-lightgrey" />
</a>
<img src="https://img.shields.io/bundlephobia/minzip/@yaireo/tagify" />
<img src="https://img.shields.io/npm/dw/@yaireo/tagify" />
</p>
<p align="center">
<img src="/docs/mix3.gif?sanitize=true" />
<img src="/docs/demo.gif?sanitize=true" />
</p>
## Table of Contents
<!--ts-->
- [Table of Contents](#table-of-contents)
- [Installation](#installation)
- [Option 1 - import from CDN:](#option-1---import-from-cdn)
- [option 2 - import as a *Node module*:](#option-2---import-as-a-node-module)
- [Usage (in your bundle):](#usage-in-your-bundle)
- [Features](#features)
- [Building the project](#building-the-project)
- [Adding tags dynamically](#adding-tags-dynamically)
- [Output value](#output-value)
- [Modify original input value format](#modify-original-input-value-format)
- [Ajax whitelist](#ajax-whitelist)
- [Edit tags](#edit-tags)
- [Validations](#validations)
- [Drag & Sort](#drag--sort)
- [Integration example:](#integration-example)
- [DOM Templates](#dom-templates)
- [Example of overriding the `tag` template:](#example-of-overriding-the-tag-template)
- [Suggestions list](#suggestions-list)
- [Example for a suggestion item alias](#example-for-a-suggestion-item-alias)
- [Example whitelist:](#example-whitelist)
- [Mixed-Content](#mixed-content)
- [Single-Value](#single-value)
- [React](#react)
- [Update regarding `onChange` prop:](#update-regarding-onchange-prop)
- [Updating the component's state](#updating-the-components-state)
- [jQuery version](#jquery-version)
- [CSS Variables](#css-variables)
- [Full list of Tagify's SCSS variables](#full-list-of-tagifys-scss-variables)
- [Methods](#methods)
- [Events](#events)
- [Hooks](#hooks)
- [Settings](#settings)
<!--te-->
## Installation
### Option 1 - import from CDN:
Place these lines before any other code which is (or will be) using *Tagify* ([Example here](https://jsbin.com/jekuqap/edit?html))
```html
<script src="https://unpkg.com/@yaireo/tagify"></script>
<script src="https://unpkg.com/@yaireo/tagify/dist/tagify.polyfills.min.js"></script>
<link href="https://unpkg.com/@yaireo/tagify/dist/tagify.css" rel="stylesheet" type="text/css" />
```
`Tagify` will then be available globally.
To load specific version use `@` - for example: `unpkg.com/@yaireo/tagify@3.1.0`
### option 2 - import as a *Node module*:
```sh
npm i @yaireo/tagify --save
```
#### Usage (in your bundle):
[live demo using Parcel as bundler](https://codesandbox.io/s/simple-tagify-setup-6pfi2)
```js
import Tagify from '@yaireo/tagify'
var tagify = new Tagify(...)
```
> Don't forget to **include `tagify.css`** file in your project.
> CSS location: `@yaireo/tagify/dist/tagify.css`
> SCSS location: `@yaireo/tagify/src/tagify.scss`
> [See SCSS usecase & example](https://github.com/yairEO/tagify/pull/282)
## Features
* Can be applied on input & textarea elements
* Supports [mix content](#mixed-content) (text and tags together)
* Supports [single-value](#single-value) mode (like `<select>`)
* Supports whitelist/blacklist
* Supports Templates for: <em>component wrapper</em>, <em>tag items</em>, <em>suggestion list</em> & <em>suggestion items</em>
* Shows suggestions list (flexiable settings & styling) at *full (component) width* or *next to* the typed texted (caret)
* Allows setting suggestions' [aliases](#example-for-a-suggestion-item-alias) for easier fuzzy-searching
* Auto-suggest input as-you-type with ability to auto-complete
* Can paste in multiple values: `tag 1, tag 2, tag 3` or even newline-separated tags
* Tags can be created by Regex delimiter or by pressing the "Enter" key / focusing of the input
* Validate tags by Regex *pattern* or by function
* Tags may be [editable](#edit-tags) (double-click)
* <del>ARIA accessibility support</del>(Component too generic for any meaningful ARIA)
* Supports read-only mode to the whole componenet or per-tag
* Each tag can have any properties desired (class, data-whatever, readonly...)
* Automatically disallow duplicate tags (vis "settings" object)
* Has built-in CSS loader, if needed (Ex. <em>AJAX</em> whitelist pulling)
* Tags can be trimmed via `hellip` by giving `max-width` to the `tag` element in your `CSS`
* Easily change direction to RTL (via the SCSS file)
* <del>Internet Explorer - A polyfill script should be used: `tagify.polyfills.min.js` (in `/dist`)</del> ***(IE support has been dropped)***
* Many useful custom [events](#events)
* Original input/textarea element values kept in sync with Tagify
## Building the project
Simply run `gulp` in your terminal, from the project's path ([Gulp](https://gulpjs.com) should be installed first).
Source files are this path: `/src/`
Output files, which are automatically generated using Gulp, are in: `/dist/`
The rest of the files are most likely irrelevant.
## Adding tags dynamically
```javascript
var tagify = new Tagify(...);
tagify.addTags(["banana", "orange", "apple"])
// or add tags with pre-defined propeties
tagify.addTags([{value:"banana", color:"yellow"}, {value:"apple", color:"red"}, {value:"watermelon", color:"green"}])
```
## Output value
There are two possible ways to get the value of the tags:
1. Access the tagify's instance's `value` prop: `tagify.value` (Array of tags)
2. Access the *original* input's value: `inputElm.value` (Stringified Array of tags)
The most common way is to simply listen to the `change` event on the *original input*
```javascript
var inputElm = document.querySelector,
tagify = new Tagify (inputElm);
inputElm.addEventListener('change', onChange)
function onChange(e){
// outputs a String
console.log(e.target.value)
}
```
### [Modify original input value format](https://jsbin.com/paxijaj/edit?html,js,output)
Default format is a JSON string:<br>
`'[{"value":"cat"}, {"value":"dog"}]'`
I **recommend** keeping this because some situations might have values such as addresses (tags contain commas):<br>
`'[{"value":"Apt. 2A, Jacksonville, FL 39404"}, {"value":"Forrest Ray, 191-103 Integer Rd., Corona New Mexico"}]'`
Another example for complex tags state might be disabled tags, or ones with custom identifier *class*:<br>
*(tags can be clicked, so delevopers can choose to use this to disable/enable tags)*<br>
`'[{"value":"cat", "disabled":true}, {"value":"dog"}, {"value":"bird", "class":"color-green"}]'`
To change the format, assuming your tags have no commas and are fairly simple:
```js
var tagify = new Tagify(inputElm, {
originalInputValueFormat: valuesArr => valuesArr.map(item => item.value).join(',')
})
```
**Output:**<br>
`"cat,dog"`
## Ajax whitelist
Dynamically-loaded suggestions list (*whitelist*) from the server (as the user types) is a frequent need to many.
Tagify comes with its own loading animation, which is a very lightweight CSS-only code, and the <em>loading</em>
state is controlled by the method `tagify.loading` which accepts `true` or `false` as arguments.
Below is a basic example using the `fetch` API. I advise to abort the last request on any input before starting a new request.
<details>
<summary>Example:</summary>
```javascript
var input = document.querySelector('input'),
tagify = new Tagify(input, {whitelist:[]}),
controller; // for aborting the call
// listen to any keystrokes which modify tagify's input
tagify.on('input', onInput)
function onInput( e ){
var value = e.detail.value
tagify.whitelist = null // reset the whitelist
// https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort
controller && controller.abort()
controller = new AbortController()
// show loading animation and hide the suggestions dropdown
tagify.loading(true).dropdown.hide()
fetch('http://get_suggestions.com?value=' + value, {signal:controller.signal})
.then(RES => RES.json())
.then(function(newWhitelist){
tagify.whitelist = newWhitelist // update inwhitelist Array in-place
tagify.loading(false).dropdown.show(value) // render the suggestions dropdown
})
}
```
</details>
## Edit tags
Tags which aren't `read-only` can be edited by double-clicking them (by default)
or by changing the `editTags` *setting* to `1`, making tags editable by single-clicking them.
The value is saved on `blur` or by pressing `enter` key. Pressing `Escape` will revert the change trigger `blur`.
<kbd>ctrl</kbd><kbd>z</kbd> will revert the change if an edited tag was marked as not valid (perhaps duplicate or blacklisted)
To prevent *all* tags from being allowed to be editable, set the `editTags` setting to `false` (or `null`).<br>
To do the same but for specific tag(s), set those tags' data with `editable` property set to `false`:
```html
<input value='[{"value":"foo", "editable":false}, {"value":"bar"}]'>
```
## Validations
For "regular" tags (not *mix-mode* or *select-mode*) the easiest way is to use the `pattern` setting and use a Regex, or
apply the `pattern` attribute directly on the `input` which will be "transformed" into a *Tagify* component (for vanilla code where the `input` tag is fully accessible to develops).
If the `pattern` setting does not meet your needs, use the [`validate` setting](#settings), which recieves a *tag data object* as an argument and should return `true` if validaiton is passing, or `false`/`string` of not.
A *string* may be returned as the reason of the validation failure so it would be printed as the `title` attribute of the invalid tag.
Note - there is a setting to keep invalid tags ([`keepInvalidTags`](#settings)) and if it's set to `true`, the user can see the reason for the invalidation by
hovering the tag and see the browser's native tooltip via the `title` attribute:
```js
{
empty : "empty",
exceed : "number of tags exceeded",
pattern : "pattern mismatch",
duplicate : "already exists",
notAllowed : "not allowed"
}
```
The texts for those (invalid tags) *titles* can be customized from the settings:
```js
new Tagify(inputElement, {
texts: {
duplicate: "Duplicates are not allowed"
}
})
```
Or by directly manipulating the *Tagify* function *prototype*:
```js
Tagify.prototype.TEXTS = {...Tagify.prototype.TEXTS, {duplicate: "Duplicates are not allowed"}}
```
## Drag & Sort
To be able to sort tags by draging, a 3rd-party script is needed.
I have made a very simple *drag & drop* (~`11kb` *unminified*) script which uses [HTML5 native API](https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API) and
it is available to download via [NPM](https://www.npmjs.com/package/@yaireo/dragsort) or [Github](https://github.com/yairEO/dragsort)
but any other *drag & drop* script may possibly work. I could not find in the whole internet a decent lightweight script.
### [Integration example](https://codepen.io/vsync/pen/jOqYOVJ):
```js
var tagify = new Tagify(inputElement)
// bind "DragSort" to Tagify's main element and tell
// it that all the items with the below "selector" are "draggable"
var dragsort = new DragSort(tagify.DOM.scope, {
selector: '.'+tagify.settings.classNames.tag,
callbacks: {
dragEnd: onDragEnd
}
})
// must update Tagify's value according to the re-ordered nodes in the DOM
function onDragEnd(elm){
tagify.updateValueByDOMTags()
}
```
## DOM Templates
It's possible to control the templates for some of the HTML elements tagify is using by
modifying the `settings.templates` Object with your own custom functions which **must return** an *HTML string*.
Available templates are: `wrapper`, `tag`, `dropdown`, `dropdownItem` and the optional `dropdownItemNoMatch`
which is a special template for rendering a suggestion item (in the dropdown list) only if there were no matches found for the typed input.
[View templates](https://github.com/yairEO/tagify/blob/master/src/parts/templates.js)
### Example of overriding the `tag` template:
Each template function automaticaly gets binded with `this` pointing to the current *Tagify* instance.
It is imperative to preserve the class names and also the `this.getAttributes(tagData)` for proper functionality.
```js
new Tagify(inputElem, {
templates: {
tag(tagData, tagify){
return `<tag title="${(tagData.title || tagData.value)}"
contenteditable='false'
spellcheck='false'
tabIndex="${this.settings.a11y.focusableTags ? 0 : -1}"
class="${this.settings.classNames.tag} ${tagData.class ? tagData.class : ""}"
${this.getAttributes(tagData)}>
<x title='' class="${this.settings.classNames.tagX}" role='button' aria-label='remove tag'></x>
<div>
<span class="${this.settings.classNames.tagText}">${tagData[this.settings.tagTextProp] || tagData.value}</span>
</div>
</tag>`
}
})
```
## Suggestions list
<p align="center">
<img src="/docs/suggestions-list.apng" alt='suggestions list dropdown'/>
</p>
The suggestions list is a *whitelist Array* of *Strings* or *Objects* which was set in the [settings](#settings) Object when the Tagify instance was created, and can be set latet directly on the instance: `tagifyInstance.whitelist = ["tag1", "tag2", ...]`.
The suggestions dropdown will be appended to the document's `<body>` element and will be rendered by default in a position below (bottom of) the Tagify element.
Using the keyboard arrows up/down will highlight an option from the list, and hitting the Enter key to select.
It is possible to tweak the list dropdown via 2 settings:
- `enabled` - this is a numeral value which tells Tagify when to show the suggestions dropdown, when a minimum of N characters were typed.
- `maxItems` - Limits the number of items the suggestions list will render
```javascript
var input = document.querySelector('input'),
tagify = new Tagify(input, {
whitelist : ['aaa', 'aaab', 'aaabb', 'aaabc', 'aaabd', 'aaabe', 'aaac', 'aaacc'],
dropdown : {
classname : "color-blue",
enabled : 0, // show the dropdown immediately on focus
maxItems : 5,
position : "text", // place the dropdown near the typed text
closeOnSelect : false, // keep the dropdown open after selecting a suggestion
highlightFirst: true
}
});
```
<p align="center"><b>Will render</b></p>
```html
<div class="tagify__dropdown tagify__dropdown--text" style="left:993.5px; top:106.375px; width:616px;">
<div class="tagify__dropdown__wrapper">
<div class="tagify__dropdown__item tagify__dropdown__item--active" value="aaab">aaab</div>
<div class="tagify__dropdown__item" value="aaabb">aaabb</div>
<div class="tagify__dropdown__item" value="aaabc">aaabc</div>
<div class="tagify__dropdown__item" value="aaabd">aaabd</div>
<div class="tagify__dropdown__item" value="aaabe">aaabe</div>
</div>
</div>
```
By default searching the suggestions is using [fuzzy-search](https://en.wikipedia.org/wiki/Approximate_string_matching) (see [settings](#settings)).
If you wish to assign *alias* to items (in your suggestion list), add the `searchBy` property to *whitelist* items you wish
to have an *alias* for.
In the below example, typing a part of a string which is included in the `searchBy` property, for example *`land midd"`* -
the suggested item which match the value "Israel" will be rendered in the suggestions (dropdown) list.
### [Example](https://yaireo.github.io/tagify/#section-extra-properties) for a suggestion item alias
```javascript
whitelist = [
...
{ value:'Israel', code:'IL', searchBy:'holy land, desert, middle east' },
...
]
```
Another handy setting is `dropdown.searchKeys` which, like the above `dropdown.searchBy` setting, allows
expanding the search of any typed terms to more than the `value` property of the whitelist items (if items are a *Collection*).
### Example whitelist:
```javascript
[
{
value : 123456,
nickname : "foo",
email : "foo@mail.com"
},
{
value : 987654,
nickname : "bar",
email : "bar@mail.com"
},
...more..
]
```
// setting to search in other keys:
```javascript
{
dropdown: {
searchKeys: ["nickname", "email"] // fuzzy-search matching for those whitelist items' properties
}
}
```
## Mixed-Content
[See demo here](https://yaireo.github.io/tagify/#section-mix)
This feature must be toggled using these [settings](#settings):
```js
{
// mixTagsInterpolator: ["{{", "}}"], // optional: interpolation before & after string
mode: 'mix', // <-- Enable mixed-content
pattern: /@|#/ // <-- Text starting with @ or # (if single, String can be used here instead of Regex)
}
```
When mixing text with tags, the original textarea (or input) element will have a value as follows:
[[cartman]] and [[kyle]] do not know [[Homer simpson]]
If the inital value of the textarea or input is formatted as the above example, tagify will try to
automatically convert everything between `[[` & `]]` to a tag, if tag exists in the *whitelist*, so make
sure when the Tagify instance is initialized, that it has tags with the correct `value` property that match
the same values that appear between `[[` & `]]`.
Applying the setting `dropdown.position:"text"` is encouraged for mixed-content tags, because the suggestions list
weird when there is already a lot of content at multiple lines.
If a tag does not exists in the *whitelist*, it may be created by the user and all you should do is listen to the `add` event and update your local/remote state.
## Single-Value
Similar to native `<Select>` element, but allows typing text as value.
## React
See [**live demo**](https://codesandbox.io/s/tagify-react-wrapper-oempc) for React integration examples.
⚠️ Tagify is **not** a [controlled component](https://github.com/yairEO/tagify/issues/489#issuecomment-629316093).
A Tagify React component is exported from [`react.tagify.js`](https://github.com/yairEO/tagify/blob/master/dist/react.tagify.js):
---
### Update regarding `onChange` prop:
I have changed how the `onChange` works internally within the Wrapper of Tagify
so as of *March 30, 2021* the `e` argument will include a `detail` parameter with the value as string.
There is no more `e.target`, and to access the original DOM input element, do this: `e.detail.tagify.DOM.originalInput`.
----
> Note: You will need to inport Tagify's CSS also, either by javasceript or by SCSS `@import` (which is preferable)
> Also note that you will need to use [*dart-sass*](https://www.npmjs.com/package/sass) and not *node-sass* in order to compile the file.
```javascript
import Tags from "@yaireo/tagify/dist/react.tagify" // React-wrapper file
import "@yaireo/tagify/dist/tagify.css" // Tagify CSS
// on tag add/edit/remove
const onChange = useCallback((e) => {
console.log("CHANGED:"
, e.detail.tagify.value // Array where each tag includes tagify's (needed) extra properties
, e.detail.tagify.getCleanValue()) // Same as above, without the extra properties
, e.detail.value // a string representing the tags
)
}, [])
const App = () => {
return (
<Tags
tagifyRef={tagifyRef} // optional Ref object for the Tagify instance itself, to get access to inner-methods
settings={settings} // tagify settings object
defaultValue="a,b,c"
{...tagifyProps} // dynamic props such as "loading", "showDropdown:'abc'", "value"
onChange={onChange}
/>
)
})
```
To gain full access to Tagify's (instance) inner methods, A custom `ref` can be used:
```jsx
...
const tagifyRef = useRef()
...
<Tags tagifyRef={tagifyRef} ... />
// or mix-mode
<MixedTags
settings={...}
onChange={...}
defaultValue={`This is a textarea which mixes text with [[{"value":"tags"}]].`}
/>
```
`<MixedTags>` component is a shorthand for `<Tags InputMode="textarea">`
#### Updating the component's state
The `settings` prop is **only used once** in the initialization process, please do not update it afterwards.
---
<details>
<summary>📖 List of (React) props for the <code>&lt;Tags/&gt;</code> component</summary>
Prop | Type | Updatable | Info
----------------------- | ------------------------- |:---------:| -----------------------------------------------------------
settings | <sub>Object</sub> | | See [*settings* section](#settings)
name | <sub>String</sub> | ✔ | `<input>`'s element `name` attribute
value | <sub>String/Array</sub> | ✔ | Initial value.
defaultValue | <sub>String/Array</sub> | | Same as `value prop
placeholder | <sub>String</sub> | ✔ | placeholder text for the component
readOnly | <sub>Boolean</sub> | ✔ | Toggles `readonly` state. With capital `O`.
tagifyRef | <sub>Object</sub> | | `useRef` hook refference for the component inner instance of vailla *Tagify* (for methods access)
showFilteredDropdown | <sub>Boolean/String</sub> | ✔ | if `true` shows the suggestions dropdown. if assigned a String, show the dropdown pre-filtered.
loading | <sub>Boolean</sub> | ✔ | Toggles `loading` state for the whole component
whitelist | <sub>Array</sub> | ✔ | Sets the `whitelist` which is the basis for the suggestions dropdown & autocomplete
className | <sub>String</sub> | | Component's optional class name to be added
InputMode | <sub>String</sub> | | `"textarea"` will create a `<textarea>` (hidden) element instead of the default `<input>` and automatically make Tagify act as [*"mix mode"*](#mixed-content)
autoFocus | <sub>Boolean</sub> | | Should the component have focus on mount. Must be unique, per-page.
children | <sub>String/Array</sub> | | `value`/`defaultValue` props are prefered
onChange | <sub>Function</sub> | | See [*events* section](#events)
onInput | <sub>Function</sub> | | See [*events* section](#events)
onAdd | <sub>Function</sub> | | See [*events* section](#events)
onRemove | <sub>Function</sub> | | See [*events* section](#events)
onInvalid | <sub>Function</sub> | | See [*events* section](#events)
onClick | <sub>Function</sub> | | See [*events* section](#events)
onKeydown | <sub>Function</sub> | | See [*events* section](#events)
onFocus | <sub>Function</sub> | | See [*events* section](#events)
onBlur | <sub>Function</sub> | | See [*events* section](#events)
onEditInput | <sub>Function</sub> | | See [*events* section](#events)
onEditBeforeUpdate | <sub>Function</sub> | | See [*events* section](#events)
onEditUpdated | <sub>Function</sub> | | See [*events* section](#events)
onEditStart | <sub>Function</sub> | | See [*events* section](#events)
onEditKeydown | <sub>Function</sub> | | See [*events* section](#events)
onDropdownShow | <sub>Function</sub> | | See [*events* section](#events)
onDropdownHide | <sub>Function</sub> | | See [*events* section](#events)
onDropdownSelect | <sub>Function</sub> | | See [*events* section](#events)
onDropdownScroll | <sub>Function</sub> | | See [*events* section](#events)
onDropdownNoMatch | <sub>Function</sub> | | See [*events* section](#events)
onDropdownUpdated | <sub>Function</sub> | | See [*events* section](#events)
</details>
---
## jQuery version
`jQuery.tagify.js`
A jQuery wrapper verison is also available, but I advise not using it because it's basically the exact same as the "normal"
script (non-jqueryfied) and all the jQuery's wrapper does is allowing to chain the event listeners for ('add', 'remove', 'invalid')
```javascript
$('[name=tags]')
.tagify()
.on('add', function(e, tagData){
console.log('added', ...tagData) // data, index, and DOM node
});
```
Accessing methods can be done via the [`.data('tagify')`](https://api.jquery.com/data):
```javascript
$('[name=tags]').tagify();
// get tags from the server (ajax) and add them:
$('[name=tags]').data('tagify').addTags('aaa, bbb, ccc')
````
## HTML input & textarea attributes
The below list of *attributes* affect *Tagify*.<br>
These can also be set by Tagify [settings](#settings) Object manually, and not *declerativly* (via attributes).
Attribute | Example | Info
----------------- | ----------------------------------------------------- | --------------------
[pattern](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/pattern) | <pre lang=html>`<input pattern='^[A-Za-z_✲ ]{1,15}$'>`</pre> | Tag Regex pattern which tag input is validated by.
[placeholder](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefplaceholder) | <pre lang=html>`<input placeholder='please type your tags'>`</pre> | This attribute's value will be used as a constant placeholder, which is visible unless something is being typed.
readOnly | <pre lang=html>`<input readOnly>`</pre> | No user-interaction (add/remove/edit) allowed.
autofocus | <pre lang=html>`<input autofocus>`</pre> | Automatically focus the the Tagify component when the component is loaded
required | <pre lang=html>`<input required>`</pre> | Adds a `required` attribute to the Tagify wrapper element. Does nothing more.
## FAQ
List of questions & scenarios which might come up during development with Tagify:
<details>
<summary><strong>Dynamic whitelist</strong></summary>
The whitelist initial value is set like so:
```javascript
const tagify = new Tagify(tagNode, {
whitelist: ["a", "b", "c"]
})
```
If changes to the whitelist are needed, they should be done like so:
**Incorrect:**
```js
tagify.settings.whitelist = ["foo", "bar"]
```
**Correct:**
```js
// set the whitelist directly on the instance and not on the "settings" property
tagify.whitelist = ["foo", "bar"]
```
</details>
---
<details>
<summary><strong>tags/whitelist data structure</strong></summary>
Tagify does not accept just *any* kind of data structure.<br>
If a tag data is represented as an `Object`, it **must** contain a **unique** property `value`
which Tagify uses to check if a tag already exists, among other things, so make sure it is present.
**Incorrect:**
```javascript
[{ "id":1, "name":"foo bar" }]
```
**Correct:**
```javascript
[{ "id":1, "value": 1, "name":"foo bar" }]
```
```javascript
[{ "value":1, "name":"foo bar" }]
```
```javascript
[{ "value":"foo bar" }]
```
```javascript
// ad a simple array of Strings
["foo bar"]
```
</details>
---
<details>
<summary><strong>Save changes (Ex. to a server)</strong></summary>
In framework-less projects, the developer should save the state of the Tagify component (somewhere), and
the question is:<br/>
**when should the state be saved?**
On every change made to *Tagify's* internal state (`tagify.value` via the `update()` method).<br>
```javascript
var tagify = new Tagify(...)
// listen to "change" events on the "original" input/textarea element
tagify.DOM.originalInput.addEventListener('change', onTagsChange)
// This example uses async/await but you can use Promises, of course, if you prefer.
async function onTagsChange(e){
const {name, value} = e.target
// "imaginary" async function "saveToServer" should get the field's name & value
await saveToServer(name, value)
}
```
If you are using *React/Vue/Angular* or any "modern" framework, then you already know how to
attach "onChange" event listeners to your `<input>`/`<textarea>` elements, so the above is irrelevant.
</details>
----
<details>
<summary><strong>Render tags in one single line</strong></summary>
Stopping tags from wrapping to new lines, add this to your `.tagify` *selector CSS Rule*:
```css
flex-wrap: nowrap;
````
</details>
----
<details>
<summary><strong>Submit on `Enter` key</strong></summary>
Tagify internally has `state` property, per `Tagify` instance
and this may be useful for a variety of things when implementing a specific scenario.
```js
var tagify = new Tagify(...)
var formElm = document.forms[0]; // just an example
tagify.on('keydown', onTagifyKeyDown)
function onTagifyKeyDown(e){
if( e.key == 'Enter' && // "enter" key pressed
!tagify.state.inputText && // assuming user is not in the middle oy adding a tag
!tagify.state.editing // user not editing a tag
){
setTimeout(() => formElm.submit()) // put some buffer to make sure tagify has done with whatever, to be on the safe-side
}
}
```
</details>
---
* [Double-click tag fires both "edit" & "click" custom events](https://github.com/yairEO/tagify/issues/247)
* [Manualy open the suggestions dropdown](https://github.com/yairEO/tagify/issues/254)
* [Render your own suggestions dropdown](https://github.com/yairEO/tagify/issues/244)
* [Allow max length on mix mode](https://github.com/yairEO/tagify/issues/252)
* [Always show dropdown](https://github.com/yairEO/tagify/issues/253)
* [Limit the length of a tag value (minimum & maximum)](https://github.com/yairEO/tagify/issues/245)
* [*Mixed mode* initial value](https://github.com/yairEO/tagify/issues/237)
* [Random colors for each tag](https://github.com/yairEO/tagify/issues/223)
* [Format input value for server side](https://github.com/yairEO/tagify/issues/220)
* [Writing to tagify textarea](https://github.com/yairEO/tagify/issues/294)
* [Scroll all tags within one line, instead of growing vertically](https://github.com/yairEO/tagify/issues/145)
* [Insert emoji at caret location when editing a tag](https://github.com/yairEO/tagify/issues/365)
* [propagate `change` event](https://github.com/yairEO/tagify/issues/413)
* [Manually update tag data after it was added](https://github.com/yairEO/tagify/issues/433)
* [Ajax Whitelist with "enforceWhitelist" setting enabled](https://github.com/yairEO/tagify/issues/465)
* [Custom (multiple) tag validation & AJAX](https://github.com/yairEO/tagify/issues/474)
* [Make tags from pasted multi-line text](https://github.com/yairEO/tagify/issues/160)
* [Add a tag at *caret* position in *mixed mode*](https://github.com/yairEO/tagify/issues/524#issuecomment-699140465)
* [Change automatic title tooltips for invalid tags]([https0465](https://github.com/yairEO/tagify/issues/862))
## CSS Variables
> Learn more about [CSS Variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties)) (custom properties)
Tagify's utilizes *CSS variables* which allow easy customization without the need to manually write CSS.
If you do wish to heavily style your Tagify components, then you can (and should) use the below variables within
your modified styles as much as you can.
For a *live* example, see the [demos page](https://yaireo.github.io/tagify/#section-different-look).
Name | Info
------------------------------- | --------------------------------
--tags-border-color | The outer border color which surrounds tagify
--tags-hover-border-color | *hover* state
--tags-focus-border-color | *focus* state
--tag-bg | Tag background color
--tag-hover | Tag background color on hover (mouse)
--tag-text-color | Tag text color
--tag-text-color--edit | Tag text color when a Tag is being edited
--tag-pad | Tag padding, from all sides. Ex. `.3em .5em`
--tag--min-width | Minimum Tag width
--tag--max-width | Maximum tag width, which gets trimmed with *hellip* after
--tag-inset-shadow-size | This is the inner shadow size, which dictates the color of the Tags.<br>It's important the size fits *exactly* to the tag.<br>Change this if you change the `--tag-pad` or fontsize.
--tag-invalid-color | For border color of edited tags with invalid value being typed into them
--tag-invalid-bg | Background color for invalid Tags.
--tag-remove-bg | Tag background color when hovering the `×` button.
--tag-remove-btn-color | Remove (`×`) button text color
--tag-remove-btn-bg | Remove (`×`) button background color
--tag-remove-btn-bg--hover | Remove (`×`) button hover background color
--loader-size | Loading animation size. `1em` is pretty big, default is a bit less.
--tag-hide-transition | Controls the transition property when a tag is removed. default is '.3s'
--placeholder-color | Placeholder text color
--placeholder-color-focus | Placeholder text color when Tagify has focus and no input was typed
--input-color | Input text color
### Full list of Tagify's [SCSS variables](https://github.com/yairEO/tagify/blob/master/src/tagify.scss#L9-L24)
## Methods
`Tagify` is [prototype](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Object_prototypes) based and There are many methods, but I've chosen to list the most relevant ones:
Name | Parameters | Info
-------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------
`destroy` | | Reverts the input element back as it was before Tagify was applied
`removeAllTags` | | Removes all tags and resets the original input tag's value property
`addTags` | <ol><li>`Array`/`String`/`Object` tag(s) to add</li><li>`Boolean` clear input after adding</li><li>`Boolean` - skip adding invalids</li><ol> | Accepts a String (word, single or multiple with a delimiter), an Array of Objects (see above) or Strings.
`addMixTags` | `Array`/`String` | Bypasses the normalization process in `addTags`, forcefully adding tags at the last caret location or at the end, if there's no last caret location saved (at `tagify.state.selection`)
`removeTags` | <ol><li>`Array`/`HTMLElement`/`String` tag(s) to remove</li><li>`silent` does not update the component's value</li><li>`tranDuration` Transition duration (in `ms`)</li></ul> | (#502) Remove single/multiple Tags. When nothing passed, removes last tag. <ul><li>`silent` - A flag, which when turned on, does not remove any value and does not update the original input value but simply removes the tag from tagify</li><li>`tranDuration` - delay for animation, after which the tag will be removed from the DOM</li></ul>
`addEmptyTag` | `Object` <sub>(`tagData`)</sub> | Create an empty tag (optionally with pre-defined data) and enters "edit" mode directly. [See demo](https://yaireo.github.io/tagify#section-different-look)
`loadOriginalValues` | `String`/`Array` | Converts the input's value into tags. This method gets called automatically when instansiating Tagify. Also works for mixed-tags
`getWhitelistItemsByValue` | `Object` | `{value}` - return an Array of found matching items (case-insensitive)
`getTagIndexByValue` | `String` | Returns the index of a specific tag, by value
`getTagElmByValue` | `String` | Returns the first matched tag node, if found
`isTagDuplicate` | `String` | Returns how many tags already exists with that value
`parseMixTags` | `String` | Converts a String argument (`[[foo]] and [[bar]] are..`) into HTML with mixed tags & texts
`getTagElms` | | Returns a DOM nodes list of all the tags
`getTagElmByValue` | `String` | Returns a specific tag DOM node by value
`tagData` | `HTMLElement`, `Object` | set/get tag data on a tag element (has`.tagify__tag` class by default)
`editTag` | `HTMLElement` | Goes to edit-mode in a specific tag
`replaceTag` | `tagElm`, `Object` <sub>(`tagData`)</sub> | Exit a tag's edit-mode. if "tagData" exists, replace the tag element with new data and update Tagify value
`loading` | `Boolean` | toggle loading state on/off (Ex. AJAX whitelist pulling)
`tagLoading` | `HTMLElement`, Boolean | same as above but for a specific tag element
`createTagElem` | `Object` <sub>(`tagData`)</sub> | Returns a tag element from the supplied tag data
`injectAtCaret` | `HTMLElement` <sub>(`injectedNode`)</sub>, `Object` <sub>(`range`)</sub> | Injects text or HTML node at last caret position. `range` parameter is *optional*
`placeCaretAfterNode` | `HTMLElement` | Places the caret after a given node
`insertAfterTag` | `HTMLElement` <sub>(tag element)</sub>, `HTMLElement`/`String` <sub>(whatever to insert after)</sub> |
`toggleClass` | `Boolean` | Toggles `class` on the main *tagify* container (`scope`)
`dropdown.selectAll` | | Add **all** whitelist items as tags and close the suggestion dropdown
`dropdown.show` | `String` | Shows the sugegstions list dropdown. A string paramater allows filtering the results
`dropdown.hide` | `Boolean` | Hides the suggestions list dropdown (if it's not managed manually by the developer)
`dropdown.toggle` | `Boolean` | Toggles dropdown show/hide. the boolean parameter will force-show
`updateValueByDOMTags` | | Iterate tag DOM nodes and re-build the `tagify.value` array (call this if tags get sorted manually)
`parseTemplate` | `String`/`Function` <sub>(template name or function)</sub>, `Array` <sub>(data)</sub> | converts a template string (by selecting one from the `settings.templates` by name or supplying a template function which returns a String) into a DOM node
`setReadonly` | `Boolean` | Toggles "readonly" mode on/off
`setDisabled` | `Boolean` | Toggles "disabled" mode on/off
## Events
All triggered events return the instance's scope (tagify).<br>
See `e.detail` for custom event additional data.
<details>
<summary>Example 1</summary>
```javascript
var tagify = new Tagify(...)
// events can be chainable, and multiple events may be binded for the same callback
tagify
.on('input', e => console.log(e.detail))
.on('edit:input edit:updated edit:start edit:keydown', e => console.log(e.type, e.detail))
```
</details>
<details>
<summary>Example 2</summary>
```javascript
var tagify = new Tagify(inputNode, {
callbacks: {
"change": (e) => console.log(e.detail))
"dropdown:show": (e) => console.log(e.detail))
}
})
```
</details>
Name | Info
------------------ | --------------------------------------------------------------------------
change | Any change to the value has occured. `e.details.value` callback listener argument is a *String*
add | A tag has been added
remove | A tag has been removed ([use `removeTag`](https://github.com/yairEO/tagify/issues/222) instead with *jQuery*)
invalid | A tag has been added but did not pass vaildation. See [event detail](https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Creating_and_triggering_events)
input | [Input](https://developer.mozilla.org/en-US/docs/Web/Events/input) event, when a tag is being typed/edited. `e.detail` exposes `value`, `inputElm` & `isValid`
click | Clicking a tag. Exposes the tag element, its index & data
dblclick | Double-clicking a tag
keydown | When tagify input has focus and a key was pressed
focus | The component currently has focus
blur | The component lost focus
edit:input | Typing inside an edited tag
edit:beforeUpdate | Just before a tag has been updated, while still in "edit" mode
edit:updated | A tag as been updated (changed view editing or by directly calling the `replaceTag()` method)
edit:start | A tag is now in "edit mode"
edit:keydown | keydown event while an edited tag is in focus
dropdown:show | Suggestions dropdown is to be rendered. The dropdown DOM node is passed in the callback, [see demo](https://yaireo.github.io/tagify/#section-basic).
dropdown:hide | Suggestions dropdown has been removed from the DOM
dropdown:select | Suggestions dropdown item selected (by mouse/keyboard/touch)
dropdown:scroll | Tells the percentage scrolled. (`event.detail.percentage`)
dropdown:noMatch | No whitelist suggestion item matched for the the typed input. At this point it is possible to manually set `tagify.suggestedListItems` to any possible custom value, for example: `[{ value:"default" }]`
dropdown:updated | Fired when the dropdown list is re-filtered while suggestions list is visible and a tag was removed so it was re-added as a suggestion
## Hooks
**Promise**-based hooks for *async* program flow scenarios.
Allows to "hook" (intervene) at certain points of the program, which were selected as a suitable place to
**pause** the program flow and wait for further instructions on how/if to procceed.
<details>
<summary>For example, if a developer wishes to add a (native) confirmation popup before a tag is removed (by a user action):
</summary>
```javascript
var input = document.querySelector('input')
var tagify = new Tagify(input,{
hooks: {
/**
* Removes a tag
* @param {Array} tags [Array of Objects [{node:..., data:...}, {...}, ...]]
*/
beforeRemoveTag : function( tags ){
return new Promise((resolve, reject) => {
confirm("Remove " + tags[0].data.value + "?")
? resolve()
: reject()
})
}
}
})
```
</details>
Name | Parameters | Info
---------------------- | ------------------------------------------- | --------------------------------------------------------------------------
beforeRemoveTag | Array <sub>(of Objects)</sub> | [Example](https://jsbin.com/xoseyux/edit?html,js,output)
suggestionClick | Object <sub>(click event data)</sub> | [Example](https://jsbin.com/tuwihuf/edit?html,js,output)
beforePaste | `tagify`, `pastedText`, `clipboardData` | Before pasted text was added to Tagify. *Resolve* with new paste value if needed
## [Settings](https://github.com/yairEO/tagify/blob/master/src/parts/defaults.js#L1)
Name | Type | Default | Info
------------------------- | ---------------------------- | ------------------------------------------- | --------------------------------------------------------------------------
tagTextProp | <sub>String</sub> | `"value"` | Tag data Object property which will be displayed as the tag's text. Remember to keep "value" property <em>unique</em>. See Also: `dropdown.mapValueTo`, `dropdown.searchKeys`
placeholder | <sub>String</sub> | | Placeholder text. If this attribute is set on an input/textarea element it will override this setting
delimiters | <sub>String</sub> | `","` | [RegEx **string**] split tags by any of these delimiters. Example delimeters: ",&#124;.&#124; " (*comma*, *dot* or *whitespace*)
pattern | <sub>String/RegEx</sub> | null | Validate input by RegEx pattern (can also be applied on the input itself as an attribute) Ex: `/[1-9]/`
mode | <sub>String</sub> | null | Use `select` for single-value dropdown-like select box. See `mix` as value to allow mixed-content. The 'pattern' setting must be set to some character.
mixTagsInterpolator | <sub>Array</sub> | <sub>`['[[', ']]']`</sub> | Interpolation for mix mode. Everything between these will become a tag
mixTagsAllowedAfter | <sub>RegEx</sub> | <sub>`/,\|\.\|\:\|\s/`</sub> | Define conditions in which typed mix-tags content is allowing a tag to be created after.
duplicates | <sub>Boolean</sub> | false | Should duplicate tags be allowed or not
trim | <sub>Boolean</sub> | true | If `true` trim the tag's value (remove before/after whitespaces)
enforceWhitelist | <sub>Boolean</sub> | false | Should ONLY use tags allowed in whitelist.<br>In `mix-mode`, setting it to `false` will not allow creating new tags.
userInput | <sub>Boolean</sub> | true | Disable manually typing/pasting/editing tags (tags may only be added from the whitelist)
autoComplete.enabled | <sub>Boolean</sub> | true | Tries to suggest the input's value while typing (match from whitelist) by adding the rest of term as grayed-out text
autoComplete.rightKey | <sub>Boolean</sub> | false | If `true`, when `` is pressed, use the suggested value to create a tag, else just auto-completes the input. In mixed-mode this is ignored and treated as "true"
whitelist | <sub>Array</sub> | [] | An array of allowed tags (*Strings* or *Objects*). When using *Objects* in the *whitelist* array a `value` property is a must & should be unique. <br/>Also, the *whitelist used for auto-completion when `autoCompletion.enabled` is `true`
blacklist | <sub>Array</sub> | [] | An array of tags which aren't allowed
addTagOnBlur | <sub>Boolean</sub> | true | Automatically adds the text which was inputed as a tag when blur event happens
pasteAsTags | <sub>Boolean</sub> | true | Automatically converts pasted text into tags
callbacks | <sub>Object</sub> | {} | Exposed callbacks object to be triggered on events: `'add'` / `'remove'` tags
maxTags | <sub>Number</sub> | Infinity | Maximum number of allowed tags. when reached, adds a class "tagify--hasMaxTags" to `<Tags>`
editTags | <sub>Object/Number</sub> | {} | `false` or `null` will disallow editing
editTags.*clicks* | <sub>Number</sub> | 2 | Number of clicks to enter "edit-mode": 1 for single click. Any other value is considered as double-click
editTags.*keepInvalid* | <sub>Boolean</sub> | true | keeps invalid edits as-is until `esc` is pressed while in focus
templates | <sub>Object</sub> | <sub>`wrapper`, `tag`, `dropdownItem`</sub> | Object consisting of functions which return template strings
validate | <sub>Function</sub> | | If the `pattern` setting does not meet your needs, use this function, which recieves *tag data object* as an argument and should return `true` if validaiton passed or `false`/`string` of not. A *string* may be returned as the reason of the validation failure.
transformTag | <sub>Function</sub> | | Takes a tag data as argument and allows mutating it before a tag is created or edited.<br>Should not `return` anything, only **mutate**.
keepInvalidTags | <sub>Boolean</sub> | false | If `true`, do not remove tags which did not pass validation
skipInvalid | <sub>Boolean</sub> | false | If `true`, do not add invalid, temporary, tags before automatically removing them
backspace | <sub>*</sub> | true | On pressing backspace key:<br> `true` - remove last tag <br>`edit` - edit last tag<br>`false` - do nothing (useful for outside style)
originalInputValueFormat | <sub>Function</sub> | | If you wish your original input/textarea `value` property format to other than the default (which I recommend keeping) you may use this and make sure it returns a *string*.
mixMode.*insertAfterTag* | <sub>Node/String</sub> | `\u00A0` | `node` or `string` to add after a tag added |
a11y.*focusableTags* | <sub>Boolean</sub> | false | allows tags to get focus, and also to be deleted via <kbd>Backspace</kbd>
dropdown.*enabled* | <sub>Number</sub> | 2 | Minimum characters input for showing a suggestions list. `false` will not render a suggestions list.
dropdown.*caseSensitive* | <sub>Boolean</sub> | false | if `true`, match **exact** item when a suggestion is selected (from the dropdown) and also more strict matching for dulpicate items. **Ensure** `fuzzySearch` is `false` for this to work.
dropdown.*maxItems* | <sub>Number</sub> | 10 | Maximum items to show in the suggestions list
dropdown.*classname* | <sub>String</sub> | `""` | Custom *classname* for the dropdown suggestions list
dropdown.*fuzzySearch* | <sub>Boolean</sub> | true | Enables filtering dropdown items values' by string *containing* and not only *beginning*
dropdown.*sortby* | <sub>String/Function</sub> | | If set as `startsWith` string, the suggestions list will be sorted with matched items which starts with the query shown first, and *exact* matches shown before all.<br><br> If this setting is defined as a `function`, it recieves two arguments: the array of filtered items and the query and it must return an Array.<br><br>(*default sorting order is same as the whitelist's*)
dropdown.*accentedSearch* | <sub>Boolean</sub> | true | Enable searching for <em>accented</em> items in the whitelist without typing exact match (#491)
dropdown.*position* | <sub>String</sub> | `"all"` | <ul><li>`manual` - will not render the dropdown, and you would need to do it yourself. [See demo](https://yaireo.github.io/tagify/#section-manual-suggestions)</li><li>`text` - places the dropdown next to the caret</li><li>`input` - places the dropdown next to the input (useful in rare situations)</li><li>`all` - normal, full-width design</li></ul>
dropdown.*highlightFirst* | <sub>Boolean</sub> | false | When a suggestions list is shown, highlight the first item, and also suggest it in the input (The suggestion can be accepted with <kbd>→</kbd> key)
dropdown.*closeOnSelect* | <sub>Boolean</sub> | true | close the dropdown after selecting an item, if `enabled:0` is set (which means always show dropdown on focus)
dropdown.*clearOnSelect* | <sub>Boolean</sub> | true | Keep typed text after selecting a suggestion
dropdown.*mapValueTo* | <sub>Function/String</sub> | | If whitelist is an Array of Objects:<br>Ex. `[{value:'foo', email:'foo@a.com'},...]`)<br> this setting controlls which data <em>key</em> will be printed in the dropdown.<br> Ex.1: `mapValueTo: data => "To:" + data.email`<br>Ex.2: `mapValueTo: "email"`
dropdown.*searchKeys* | <sub>Array</sub> | <sub>`["value", "searchBy"]`</sub> | When a user types something and trying to match the whitelist items for suggestions, this setting allows matching other keys of a whitelist objects
dropdown.*appendTarget* | <sub>HTMLNode</sub> | `document.body` | Target-Node which the *suggestions dropdown* is appended to (*only when rendered*)
dropdown.*placeAbove* | <sub>Boolean</sub> | | If defined, will force the placement of the dropdown in respect to the Boolean value: `true` will always show the suggestions dropdown above the input field and `false` will always show it below. By default this setting it not defined and the placement of the dropdown is automatically decided according to the space availble, where opening it *below* the input is preferred.

32
node_modules/@yaireo/tagify/dist/jQuery.tagify.min.js generated vendored Normal file

File diff suppressed because one or more lines are too long

8
node_modules/@yaireo/tagify/dist/react.tagify.js generated vendored Normal file
View File

@@ -0,0 +1,8 @@
/**
* Tagify (v 4.8.1) - tags input component
* By Yair Even-Or
* Don't sell this code. (c)
* https://github.com/yairEO/tagify
*/
!function(e,n){"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?module.exports=n():e.React.tagify=n()}(this,(function(){"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.default=exports.MixedTags=void 0;var e,n=function(e,n){if(!n&&e&&e.__esModule)return e;if(null===e||"object"!=typeof e&&"function"!=typeof e)return{default:e};var t=a(n);if(t&&t.has(e))return t.get(e);var o={},r=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var u in e)if("default"!==u&&Object.prototype.hasOwnProperty.call(e,u)){var c=r?Object.getOwnPropertyDescriptor(e,u):null;c&&(c.get||c.set)?Object.defineProperty(o,u,c):o[u]=e[u]}o.default=e,t&&t.set(e,o);return o}(require("react")),t=require("react-dom/server"),o=require("prop-types"),r=(e=require("./tagify.min.js"))&&e.__esModule?e:{default:e};const u=["children"];function a(e){if("function"!=typeof WeakMap)return null;var n=new WeakMap,t=new WeakMap;return(a=function(e){return e?t:n})(e)}function c(){return(c=Object.assign||function(e){for(var n=1;n<arguments.length;n++){var t=arguments[n];for(var o in t)Object.prototype.hasOwnProperty.call(t,o)&&(e[o]=t[o])}return e}).apply(this,arguments)}function d(e,n){if(null==e)return{};var t,o,r=function(e,n){if(null==e)return{};var t,o,r={},u=Object.keys(e);for(o=0;o<u.length;o++)t=u[o],n.indexOf(t)>=0||(r[t]=e[t]);return r}(e,n);if(Object.getOwnPropertySymbols){var u=Object.getOwnPropertySymbols(e);for(o=0;o<u.length;o++)t=u[o],n.indexOf(t)>=0||Object.prototype.propertyIsEnumerable.call(e,t)&&(r[t]=e[t])}return r}const i=e=>e;const l=({name:e,value:o,loading:u=!1,onInput:a=i,onAdd:c=i,onRemove:d=i,onEditInput:l=i,onEditBeforeUpdate:s=i,onEditUpdated:f=i,onEditStart:p=i,onEditKeydown:y=i,onInvalid:g=i,onClick:w=i,onKeydown:h=i,onFocus:O=i,onBlur:b=i,onChange:m=i,onDropdownShow:v=i,onDropdownHide:E=i,onDropdownSelect:j=i,onDropdownScroll:D=i,onDropdownNoMatch:M=i,onDropdownUpdated:x=i,readOnly:S,disabled:k,children:I,settings:R={},InputMode:N="input",autoFocus:P,className:T,whitelist:C,tagifyRef:U,placeholder:F="",defaultValue:_,showDropdown:V})=>{const q=(0,n.useRef)(),B=(0,n.useRef)(),K=(0,n.useRef)(),W=_||o,A=(0,n.useMemo)((()=>({ref:B,name:e,defaultValue:I||"string"==typeof W?W:JSON.stringify(W),className:T,readOnly:S,disabled:k,autoFocus:P,placeholder:F})),[]),H=(0,n.useCallback)((()=>{P&&K.current&&K.current.DOM.input.focus()}),[K]);return(0,n.useEffect)((()=>{!function(e){if(e)for(let o in e){let r=e[o];String(r).includes("jsxRuntime")&&(e[o]=(...e)=>(0,t.renderToStaticMarkup)(n.default.createElement(r,{props:e})))}}(R.templates),"textarea"==N&&(R.mode="mix"),C&&C.length&&(R.whitelist=C);const e=new r.default(B.current,R);return e.on("input",a).on("add",c).on("remove",d).on("invalid",g).on("keydown",h).on("focus",O).on("blur",b).on("click",w).on("change",m).on("edit:input",l).on("edit:beforeUpdate",s).on("edit:updated",f).on("edit:start",p).on("edit:keydown",y).on("dropdown:show",v).on("dropdown:hide",E).on("dropdown:select",j).on("dropdown:scroll",D).on("dropdown:noMatch",M).on("dropdown:updated",x),U&&(U.current=e),K.current=e,H(),()=>{e.destroy()}}),[]),(0,n.useEffect)((()=>{H()}),[P]),(0,n.useEffect)((()=>{q.current&&(K.current.settings.whitelist.length=0,C&&C.length&&K.current.settings.whitelist.push(...C))}),[C]),(0,n.useEffect)((()=>{const e=K.current.getInputValue();q.current&&!((e,n)=>{const t=e=>"string"==typeof e?e:JSON.stringify(e);return t(e)==t(n)})(o,e)&&K.current.loadOriginalValues(o)}),[o]),(0,n.useEffect)((()=>{q.current&&K.current.toggleClass(T)}),[T]),(0,n.useEffect)((()=>{q.current&&K.current.loading(u)}),[u]),(0,n.useEffect)((()=>{q.current&&K.current.setReadonly(S)}),[S]),(0,n.useEffect)((()=>{q.current&&K.current.setDisabled(k)}),[k]),(0,n.useEffect)((()=>{const e=K.current;q.current&&(V?(e.dropdown.show.call(e,V),e.toggleFocusClass(!0)):e.dropdown.hide.call(e))}),[V]),(0,n.useEffect)((()=>{q.current=!0}),[]),n.default.createElement("div",{className:"tags-input"},n.default.createElement(N,A))};l.propTypes={name:o.string,value:(0,o.oneOfType)([o.string,o.array]),loading:o.bool,children:(0,o.oneOfType)([o.string,o.array]),onChange:o.func,readOnly:o.bool,settings:o.object,InputMode:o.string,autoFocus:o.bool,className:o.string,tagifyRef:o.object,whitelist:o.array,placeholder:o.string,defaultValue:(0,o.oneOfType)([o.string,o.array]),showDropdown:(0,o.oneOfType)([o.string,o.bool]),onInput:o.func,onAdd:o.func,onRemove:o.func,onEditInput:o.func,onEditBeforeUpdate:o.func,onEditUpdated:o.func,onEditStart:o.func,onEditKeydown:o.func,onInvalid:o.func,onClick:o.func,onKeydown:o.func,onFocus:o.func,onBlur:o.func,onDropdownShow:o.func,onDropdownHide:o.func,onDropdownSelect:o.func,onDropdownScroll:o.func,onDropdownNoMatch:o.func,onDropdownUpdated:o.func};const s=n.default.memo(l);s.displayName="Tags";exports.MixedTags=e=>{let t=e.children,o=d(e,u);return n.default.createElement(s,c({InputMode:"textarea"},o),t)};var f=s;return exports.default=f,exports}));

1
node_modules/@yaireo/tagify/dist/tagify.css generated vendored Normal file

File diff suppressed because one or more lines are too long

8
node_modules/@yaireo/tagify/dist/tagify.min.js generated vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

27
node_modules/@yaireo/tagify/dist/tagify.vue generated vendored Normal file
View File

@@ -0,0 +1,27 @@
<template v-once>
<textarea v-if="mode === 'textarea'" v-model="value"/>
<input v-else :value="value" v-on:change="onChange">
</template>
<script>
import Tagify from "./tagify.min.js"
import "./tagify.css"
export default {
name: "Tags",
props: {
mode: String,
settings: Object,
value: [String, Array],
onChange: Function
},
watch: {
value(newVal, oldVal) {
this.tagify.loadOriginalValues(newVal)
},
},
mounted() {
this.tagify = new Tagify(this.$el, this.settings)
}
};
</script>

124
node_modules/@yaireo/tagify/package.json generated vendored Normal file
View File

@@ -0,0 +1,124 @@
{
"_from": "@yaireo/tagify",
"_id": "@yaireo/tagify@4.8.1",
"_inBundle": false,
"_integrity": "sha512-SK3mN6HPzNzW3rgolvdsv/3m8OX5xdbh6H8oz/fjdLCa0Qt3JoaAQqjsMxaLG575+ywl5jpvsaIj+JB6yi9LUQ==",
"_location": "/@yaireo/tagify",
"_phantomChildren": {},
"_requested": {
"type": "tag",
"registry": true,
"raw": "@yaireo/tagify",
"name": "@yaireo/tagify",
"escapedName": "@yaireo%2ftagify",
"scope": "@yaireo",
"rawSpec": "",
"saveSpec": null,
"fetchSpec": "latest"
},
"_requiredBy": [
"#USER",
"/"
],
"_resolved": "https://registry.npmjs.org/@yaireo/tagify/-/tagify-4.8.1.tgz",
"_shasum": "464708e3204b03e0991c06fb8fd12fac60f5abd2",
"_spec": "@yaireo/tagify",
"_where": "C:\\blog\\blog",
"author": {
"name": "Yair Even-Or",
"email": "vsync.design@gmail.com"
},
"browserslist": [
">1%",
"not dead",
"not ie < 11",
"not IE_Mob 11",
"not op_mini all"
],
"bugs": {
"url": "https://github.com/yaireo/tagify/issues"
},
"bundleDependencies": false,
"deprecated": false,
"description": "lightweight, efficient Tags input component in Vanilla JS / React / Angular [super customizable, tiny size & top performance]",
"devDependencies": {
"@babel/core": "^7.15.5",
"@babel/plugin-proposal-object-rest-spread": "^7.14.7",
"@babel/plugin-transform-destructuring": "^7.14.7",
"@babel/preset-env": "^7.15.4",
"@babel/preset-react": "^7.14.5",
"@rollup/plugin-babel": "^5.3.0",
"@rollup/stream": "git+https://github.com/andremacola/stream.git",
"beepbeep": "^1.3.0",
"gulp": "^4.0.2",
"gulp-autoprefixer": "^8.0.0",
"gulp-babel": "^8.0.0",
"gulp-bump": "^3.2.0",
"gulp-cached": "^1.1.1",
"gulp-clean-css": "^4.3.0",
"gulp-combine-mq": "^0.4.0",
"gulp-concat": "^2.6.1",
"gulp-css-globbing": "^0.2.2",
"gulp-eslint": "^6.0.0",
"gulp-header-comment": "^0.9.0",
"gulp-insert": "^0.5.0",
"gulp-load-plugins": "^2.0.7",
"gulp-rename": "^2.0.0",
"gulp-replace": "^1.1.3",
"gulp-sass": "^5.0.0",
"gulp-sourcemaps": "^3.0.0",
"gulp-streamify": "^1.0.2",
"gulp-tag-version": "^1.3.1",
"gulp-tap": "^2.0.0",
"gulp-terser": "^2.0.1",
"gulp-umd": "^2.0.0",
"gulp-util": "^3.0.8",
"gulp-watch": "^5.0.1",
"path": "^0.12.7",
"rollup": "^2.56.3",
"rollup-plugin-banner": "^0.2.1",
"rollup-plugin-terser": "^7.0.2",
"run-sequence": "^2.2.1",
"sass": "^1.39.0",
"semver": "^7.3.5",
"vinyl-source-stream": "^2.0.0"
},
"files": [
"/dist",
"/src/tagify.scss"
],
"homepage": "https://github.com/yairEO/tagify",
"jest": {
"preset": "jest-puppeteer"
},
"keywords": [
"tags",
"tagging",
"component",
"tag",
"ui"
],
"license": "MIT",
"main": "./dist/tagify.min.js",
"name": "@yaireo/tagify",
"np": {
"yarn": false,
"yolo": true
},
"peerDependencies": {
"prop-types": "^15.7.2"
},
"repository": {
"type": "git",
"url": "git+https://github.com/yairEO/tagify.git"
},
"scripts": {
"build": "gulp",
"prepublishOnly": "pkg-ok",
"serve": "npx http-server -o index.html -c-1",
"start": "gulp --dev",
"test": "echo \"No test specified\"",
"version": "gulp build && git add ."
},
"version": "4.8.1"
}

693
node_modules/@yaireo/tagify/src/tagify.scss generated vendored Normal file
View File

@@ -0,0 +1,693 @@
@use "sass:math";
:root {
--tagify-dd-color-primary: rgb(53,149,246); // should be same as "$tags-focus-border-color"
--tagify-dd-bg-color: white;
}
.tagify{
// SCSS "default" allows overriding variables BEFORE they are set in the below lines of code
$self: &;
$tags-border-color : #DDD !default;
$tags-hover-border-color : #CCC !default;
$tags-focus-border-color : #3595f6 !default;
$tagMargin : 5px !default;
$tag-pad : .3em .5em !default;
$tag-min-width : 1ch !default;
$tag-max-width : auto !default;
$tag-text-color : black !default;
$tag-text-color--edit : black !default;
$tag-bg : #E5E5E5 !default;
$tag-hover : #D3E2E2 !default;
$tag-remove : #D39494 !default;
$tag-remove-btn-color : $tag-text-color !default;
$tag-remove-btn-bg : none !default;
$tag-remove-btn-bg--hover: darken($tag-remove, 8) !default;
$tag-invalid-color : $tag-remove !default;
$tag-invalid-bg : rgba($tag-remove, .5) !default;
$tag-inset-shadow-size : 1.1em !default;
$tag-hide-transition : .3s !default;
$placeholder-color : rgba($tag-text-color, .4) !default;
$placeholder-color-focus : rgba($tag-text-color, .25) !default;
$input-color : inherit !default;
$tagify-dd-bg-color : white !default;
$tagify-dd-color-primary : rgb(53,149,246) !default;
// CSS variables
--tags-disabled-bg : #F1F1F1;
--tags-border-color : #{$tags-border-color};
--tags-hover-border-color : #{$tags-hover-border-color};
--tags-focus-border-color : #{$tags-focus-border-color};
--tag-bg : #{$tag-bg};
--tag-hover : #{$tag-hover};
--tag-text-color : #{$tag-text-color};
--tag-text-color--edit : #{$tag-text-color--edit};
--tag-pad : #{$tag-pad};
--tag-inset-shadow-size : #{$tag-inset-shadow-size};
--tag-invalid-color : #{$tag-invalid-color};
--tag-invalid-bg : #{$tag-invalid-bg};
--tag-remove-bg : #{rgba($tag-remove, .3)};
--tag-remove-btn-color : #{$tag-remove-btn-color};
--tag-remove-btn-bg : #{$tag-remove-btn-bg};
--tag-remove-btn-bg--hover : #{$tag-remove-btn-bg--hover};
--input-color : #{$input-color};
--tag--min-width : #{$tag-min-width};
--tag--max-width : #{$tag-max-width};
--tag-hide-transition : #{$tag-hide-transition};
--placeholder-color : #{$placeholder-color};
--placeholder-color-focus : #{$placeholder-color-focus};
--loader-size : .8em;
@mixin firefox {
@at-root {
@-moz-document url-prefix() {
& { @content; }
}
}
}
@mixin placeholder( $show:true ){
transition: .2s ease-out;
@if $show == true {
opacity: 1;
transform: none;
}
@else {
opacity: 0;
transform: translatex(6px);
}
}
@mixin loader(){
content: '';
vertical-align: middle;
opacity: 1;
width: .7em;
height: .7em;
width: var(--loader-size);
height: var(--loader-size);
border: 3px solid;
border-color: #EEE #BBB #888 transparent;
border-radius: 50%;
animation: rotateLoader .4s infinite linear;
}
@mixin tagReadonlyBG($size:5px){
background: linear-gradient(45deg, var(--tag-bg) 25%,
transparent 25%,
transparent 50%,
var(--tag-bg) 50%,
var(--tag-bg) 75%,
transparent 75%,
transparent) 0/#{$size} #{$size};
box-shadow: none;
filter: brightness(.95);
}
@keyframes tags--bump{
30% { transform: scale(1.2); }
}
@keyframes rotateLoader {
to{ transform: rotate(1turn) }
}
display : flex;
align-items : flex-start;
flex-wrap : wrap;
border : 1px solid $tags-border-color;
border : 1px solid var(--tags-border-color);
padding : 0;
line-height : normal;
cursor : text;
outline : none;
position : relative;
box-sizing : border-box;
transition : .1s;
&:hover{
border-color: $tags-hover-border-color;
border-color: var(--tags-hover-border-color);
}
&.tagify--focus{
transition: 0s;
border-color: $tags-focus-border-color;
border-color: var(--tags-focus-border-color);
}
&[disabled]{
background: var(--tags-disabled-bg);
filter: saturate(0);
opacity: .5;
pointer-events: none;
}
// Global "read-only" mode (no input button)
&[readonly]{
&#{$self}--select{
pointer-events: none;
}
&:not(#{$self}--mix):not(#{$self}--select){
cursor: default;
> #{$self}__input{
visibility: hidden;
width: 0;
margin: $tagMargin 0;
}
#{$self}__tag > div{
padding: $tag-pad;
padding: var(--tag-pad);
&::before{
@include tagReadonlyBG;
}
}
}
#{ $self }__tag__removeBtn{ display:none; }
}
&--loading{
#{ $self }__input{
> br:last-child{ display:none; }
&::before{ content:none; }
&::after{
@include loader;
content: '' !important;
margin: -2px 0 -2px .5em;
}
&:empty{
&::after{
margin-left:0;
}
}
}
}
///////////////////////////////////////////
// Hides originals
+ input,
+ textarea{
position: absolute !important;
left: -9999em !important;
transform: scale(0) !important;
}
&__tag{
display : inline-flex;
align-items: center;
margin : $tagMargin 0 $tagMargin $tagMargin;
position : relative;
z-index : 1;
outline : none;
cursor : default;
transition : .13s ease-out;
> div{ // :not([contenteditable])
vertical-align : top;
box-sizing : border-box;
max-width : 100%;
padding : $tag-pad;
padding : var(--tag-pad, $tag-pad);
color : $tag-text-color;
color : var(--tag-text-color, $tag-text-color);
line-height : inherit;
border-radius : 3px;
// user-select : none; // should allow selecting text if the user wishes to copy something
white-space : nowrap;
transition : .13s ease-out;
> *{
white-space : pre-wrap;
overflow : hidden;
text-overflow : ellipsis;
display : inline-block;
vertical-align : top;
min-width : $tag-min-width;
max-width : $tag-max-width;
min-width : var(--tag--min-width, $tag-min-width);
max-width : var(--tag--max-width, $tag-max-width);
transition : .8s ease, .1s color;
&[contenteditable]{
outline: none;
user-select: text;
cursor: text;
// fix: sometimes the caret after the last character wasn't visible (when setting {backspace:"edit"})
margin: -2px;
padding: 2px;
max-width: 350px;
}
}
&::before{
content: '';
position: absolute;
border-radius: inherit;
left:0; top:0; right:0; bottom:0;
z-index: -1;
pointer-events:none;
transition: 120ms ease;
animation : tags--bump .3s ease-out 1;
box-shadow: 0 0 0 $tag-inset-shadow-size $tag-bg inset;
box-shadow: 0 0 0 var(--tag-inset-shadow-size, $tag-inset-shadow-size) var(--tag-bg, $tag-bg) inset;
}
}
&:hover:not([readonly]),
&:focus{
div{ // :not([contenteditable])
&::before{
$size: math.div(-$tagMargin, 2);
$size: -2px;
top:$size; right:$size; bottom:$size; left:$size;
box-shadow: 0 0 0 $tag-inset-shadow-size $tag-hover inset;
box-shadow: 0 0 0 var(--tag-inset-shadow-size, $tag-inset-shadow-size) var(--tag-hover, $tag-hover) inset;
// box-shadow: 0 0 0 0 $tag-remove inset
}
// background:nth($tagColor,2);
//background:none;
// box-shadow: 0 0 0 2px $tag-hover inset;
// transition:50ms;
}
}
&--loading{
pointer-events: none;
.tagify__tag__removeBtn{
display: none;
}
&::after{
--loader-size: .4em;
@include loader;
margin: 0 .5em 0 -.1em;
}
}
&--flash{
div::before{ animation:none; }
}
&--hide{
width : 0 !important;
padding-left : 0;
padding-right : 0;
margin-left : 0;
margin-right : 0;
opacity : 0;
transform : scale(0);
transition : $tag-hide-transition;
transition : var(--tag-hide-transition, $tag-hide-transition);
pointer-events : none;
> div > *{
white-space: nowrap;
}
}
&#{ $self }{
&--noAnim{
> div::before{
animation:none;
}
}
&--notAllowed:not(.tagify__tag--editable){
div{
> span{ opacity:.5; } // filter:blur(.2px);
&::before{
box-shadow: 0 0 0 $tag-inset-shadow-size $tag-invalid-bg inset !important;
box-shadow: 0 0 0 var(--tag-inset-shadow-size, $tag-inset-shadow-size) var(--tag-invalid-bg, $tag-invalid-bg) inset !important;
transition: .2s;
}
}
}
}
&[readonly]{
#{ $self }__tag__removeBtn{ display:none; }
> div{// padding: $tag-pad;
&::before{
@include tagReadonlyBG;
}
}
}
&--editable{
> div{
color : $tag-text-color--edit;
color : var(--tag-text-color--edit, $tag-text-color--edit);
&::before{
box-shadow: 0 0 0 2px $tag-hover inset !important;
box-shadow: 0 0 0 2px var(--tag-hover, $tag-hover) inset !important;
}
}
> #{$self}__tag__removeBtn{
pointer-events: none;
&::after{
opacity: 0;
transform: translateX(100%) translateX(5px);
}
}
&.tagify--invalid{
> div{
&::before{
box-shadow: 0 0 0 2px $tag-invalid-color inset !important;
box-shadow: 0 0 0 2px var(--tag-invalid-color, $tag-invalid-color) inset !important;
}
}
}
}
&__removeBtn{
$size: 14px;
order : 5;
display : inline-flex;
align-items : center;
justify-content: center;
border-radius : 50px;
cursor : pointer;
font : #{$size}/1 Arial;
background : $tag-remove-btn-bg;
background : var(--tag-remove-btn-bg, $tag-remove-btn-bg);
color : $tag-remove-btn-color;
color : var(--tag-remove-btn-color, $tag-remove-btn-color);
width : $size;
height : $size;
margin-right : math.div($size,3);
margin-left : auto;
overflow : hidden;
transition : .2s ease-out;
&::after{
content: "\00D7";
transition: .3s, color 0s;
}
&:hover{
color: white;
background: $tag-remove-btn-bg--hover;
background: var(--tag-remove-btn-bg--hover, $tag-remove-btn-bg--hover);
// + span{ box-shadow: 0 0 0 2px $tag-remove inset; transition:.2s; }
+ div{
> span{ opacity:.5; } // filter:blur(.2px);
&::before{
box-shadow: 0 0 0 $tag-inset-shadow-size rgba($tag-remove, .3) inset !important;
box-shadow: 0 0 0 var(--tag-inset-shadow-size, $tag-inset-shadow-size) var(--tag-remove-bg, rgba($tag-remove, .3)) inset !important;
transition: box-shadow .2s;
}
}
}
}
}
&:not(#{$self}--mix){
#{ $self }__input{
// https://stackoverflow.com/a/13470210/104380
br { display:none; }
* { display:inline; white-space:nowrap; }
}
}
///////////////////////////////////////////
// Holds the placeholder & the tags input
&__input{
$placeholder-width : 110px;
flex-grow: 1;
display: inline-block;
min-width: $placeholder-width;
margin: $tagMargin;
padding: $tag-pad;
padding: var(--tag-pad, $tag-pad);
line-height: inherit;
position: relative;
white-space: pre-wrap; // #160 Line break (\n) as delimeter
color: $input-color;
color: var(--input-color, $input-color);
box-sizing: inherit;
&:empty{
@include firefox {
// clicking twice on the input (not fast) disallows typing (bug) only when the input has "display:flex".
// disabled the below rule for the above reason:
// display: flex; // https://bugzilla.mozilla.org/show_bug.cgi?id=904846#c45
}
&::before{
@include placeholder;
display: inline-block;
width: auto;
#{ $self }--mix &{
display: inline-block;
}
}
}
&:focus{
outline:none;
&::before{
@include placeholder(false);
/* ALL MS BROWSERS: hide placeholder (on focus) otherwise the caret is places after it, which is weird */
/* IE10+ CSS styles go here */
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
display: none;
}
/* IE Edge 12+ CSS styles go here */
@supports ( -ms-ime-align:auto ) {
display: none;
}
}
&:empty{
&::before{
@include placeholder(true);
// Seems to be fixed! no need for the below hack
// @include firefox {
// // remove ":after" pseudo element: https://bugzilla.mozilla.org/show_bug.cgi?id=904846#c45
// content: unset;
// // display:inline-block;
// }
color: $placeholder-color-focus;
color: var(--placeholder-color-focus);
}
&::after{
@include firefox {
display: none;
}
}
}
}
&::before{
content: attr(data-placeholder);
height: 1em;
line-height: 1em;
margin: auto 0;
z-index: 1;
color: $placeholder-color;
color: var(--placeholder-color);
white-space: nowrap;
pointer-events: none;
opacity: 0;
position: absolute;
#{$self}--mix &{
display: none;
position: static;
line-height: inherit;
}
}
/* Seems firefox newer versions don't need this any more
@supports ( -moz-appearance:none ){
&::before{
line-height: inherit;
position:relative;
}
}
*/
// tries to suggest the rest of the value from the first item in the whitelist which matches it
&::after{
content: attr(data-suggest);
display: inline-block;
white-space: pre; /* allows spaces at the beginning */
color: $tag-text-color;
opacity: .3;
pointer-events:none;
max-width: 100px;
}
// &--invalid{
// // color: $invalid-input-color;
// }
/*
in "mix mode" the tags are inside the "input" element
*/
#{ $self }__tag{
margin: 0 1px;
// line-height: 1.1;
> div{
padding-top:0; padding-bottom:0;
}
}
}
&--mix {
display: block; // display:flex makes Chrome generates <div><br></div> when pressing ENTER key
#{ $self }__input{
padding: $tagMargin;
margin: 0;
width: 100%;
height: 100%;
line-height: 1.5;
display: block; // needed to resolve this bug: https://bugs.chromium.org/p/chromium/issues/detail?id=1182621
&::before{ height:auto; }
// no suggested-complete are shown in mix-mode while higilighting dropdown options
&::after{ content:none; }
}
}
&--select{
&::after{
$size: 16px;
content: '>';
opacity: .5;
position: absolute;
top: 50%;
right: 0;
bottom: 0;
font: $size monospace;
line-height: math.div($size,2);
height: math.div($size,2);
pointer-events: none;
transform: translate(-150%, -50%) scaleX(1.2) rotate(90deg);
transition: .2s ease-in-out;
}
&[aria-expanded=true]{
&::after{
transform: translate(-150%, -50%) rotate(270deg) scaleY(1.2);
}
}
#{$self}__tag{
position: absolute;
top: 0;
right: 1.8em;
bottom: 0;
div{
display: none;
}
}
#{$self}__input{
width: 100%;
}
}
&--invalid{
--tags-border-color : #{$tag-invalid-color};
}
// Since the dropdown is an external element, which is positioned directly on the body element
// it cannot ingerit the CSS variables applied on the ".Tagify" element
&__dropdown{
$dropdown: &;
$trans: .25s cubic-bezier(0,1,.5,1);
position: absolute;
z-index: 9999;
transform: translateY(1px);
overflow: hidden;
&[placement="top"]{
margin-top: 0;
transform: translateY(-100%);
#{$dropdown}__wrapper{
border-top-width: 1.1px; // fixes - https://bugs.chromium.org/p/chromium/issues/detail?id=1147523
border-bottom-width: 0;
}
}
// when the dropdown shows next to the caret while typing
&[position="text"]{
box-shadow: 0 0 0 3px rgba(var(--tagify-dd-color-primary), .1);
font-size: .9em;
#{$dropdown}__wrapper{
border-width: 1px;
}
}
&__wrapper{
max-height: 300px;
overflow: auto;
background: $tagify-dd-bg-color;
background: var(--tagify-dd-bg-color);
border: 1px solid $tags-focus-border-color;
border-color: var(--tagify-dd-color-primary);
border-bottom-width: 1.33px; // fixes - https://bugs.chromium.org/p/chromium/issues/detail?id=1147523
border-top-width: 0;
box-shadow: 0 2px 4px -2px rgba(black,.2);
// box-sizing: border-box;
transition: $trans;
}
// intial state, pre-rendered
&--initial{
#{$dropdown}__wrapper{
max-height: 20px;
transform: translateY(-1em);
}
&[placement="top"]{
#{$dropdown}__wrapper{
transform: translateY(2em);
}
}
}
&__item{
box-sizing: inherit;
padding: $tag-pad;
margin: 1px;
cursor: pointer;
border-radius: 2px;
position: relative;
outline: none;
&--active{
background: $tagify-dd-color-primary;
background: var(--tagify-dd-color-primary);
color: white;
}
&:active{
filter: brightness(105%);
}
}
}
}

11
package-lock.json generated Normal file
View File

@@ -0,0 +1,11 @@
{
"requires": true,
"lockfileVersion": 1,
"dependencies": {
"@yaireo/tagify": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/@yaireo/tagify/-/tagify-4.8.1.tgz",
"integrity": "sha512-SK3mN6HPzNzW3rgolvdsv/3m8OX5xdbh6H8oz/fjdLCa0Qt3JoaAQqjsMxaLG575+ywl5jpvsaIj+JB6yi9LUQ=="
}
}
}

View File

@@ -1,40 +1,62 @@
package myblog.blog.article.controller;
import lombok.RequiredArgsConstructor;
import myblog.blog.article.dto.ArticleForMainView;
import myblog.blog.article.dto.NewArticleDto;
import myblog.blog.article.service.ArticleService;
import myblog.blog.category.dto.CategoryForMainView;
import myblog.blog.category.service.CategoryService;
import myblog.blog.member.auth.PrincipalDetails;
import myblog.blog.tags.service.TagsService;
import org.springframework.data.domain.Slice;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Controller
@RequiredArgsConstructor
public class ArticleController {
private final ArticleService articleService;
private final TagsService tagsService;
private final CategoryService categoryService;
@GetMapping("article/write")
public String writeArticleForm(NewArticleDto newArticleDto, Model model){
CategoryForMainView categoryForView = categoryService.getCategoryForView();
model.addAttribute("category",categoryForView);
model.addAttribute(newArticleDto);
return "articleWriteForm";
return "article/articleWriteForm";
}
@PostMapping("article/write")
@Transactional
public String WriteArticle(@ModelAttribute NewArticleDto newArticleDto, Authentication authentication){
PrincipalDetails principal = (PrincipalDetails) authentication.getPrincipal();
newArticleDto.setMemberId(principal.getMemberId());
Long articleId = articleService.writeArticle(newArticleDto);
articleService.writeArticle(newArticleDto);
return "redirect:/";
}
@GetMapping("/main/article/{pageNum}")
public @ResponseBody
List<ArticleForMainView> nextPage(@PathVariable int pageNum){
return articleService.getRecentArticles(pageNum).getContent();
}
}

View File

@@ -3,9 +3,13 @@ package myblog.blog.article.domain;
import lombok.Builder;
import lombok.Getter;
import myblog.blog.base.domain.BasicEntity;
import myblog.blog.category.domain.Category;
import myblog.blog.member.doamin.Member;
import myblog.blog.tags.domain.ArticleTagList;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@@ -27,20 +31,31 @@ public class Article extends BasicEntity {
@Column(columnDefinition = "bigint default 0",nullable = false)
private Long hit;
private String toc;
private String thumbnailUrl;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
private String thumbnailUrl;
@OneToMany(mappedBy = "article")
private List<ArticleTagList> articleTagLists = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
protected Article() {
}
@Builder
public Article(String title, String content, String toc, Member member) {
public Article(String title, String content, String toc, Member member, String thumbnailUrl, Category category) {
this.title = title;
this.content = content;
this.toc = toc;
this.member = member;
this.thumbnailUrl = thumbnailUrl;
this.hit = 0L;
this.category = category;
}
}

View File

@@ -0,0 +1,19 @@
package myblog.blog.article.dto;
import lombok.Getter;
import lombok.Setter;
import javax.validation.constraints.NotBlank;
import java.time.LocalDateTime;
@Getter
@Setter
public class ArticleForMainView {
private Long id;
private String title;
private String content;
private String thumbnailUrl;
private LocalDateTime createdDate;
}

View File

@@ -1,9 +1,16 @@
package myblog.blog.article.dto;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Getter;
import lombok.Setter;
import myblog.blog.tags.domain.Tags;
import myblog.blog.tags.dto.TagsDto;
import javax.validation.constraints.NotBlank;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Setter
@Getter
@@ -19,5 +26,8 @@ public class NewArticleDto {
private String thumbnailUrl;
private String category;
private String tags;
}

View File

@@ -0,0 +1,12 @@
package myblog.blog.article.repository;
import myblog.blog.article.domain.Article;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.repository.Repository;
public interface ArticlePagingRepository extends Repository<Article,Long> {
Slice<Article> findBy(Pageable pageable);
}

View File

@@ -1,11 +1,18 @@
package myblog.blog.article.repository;
import myblog.blog.article.domain.Article;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ArticleRepository extends JpaRepository<Article, Long> {
List<Article> findTop6ByOrderByHitDesc();
Slice<Article> findByOrderByCreatedDateDesc(Pageable pageable);
}

View File

@@ -2,27 +2,70 @@ package myblog.blog.article.service;
import lombok.RequiredArgsConstructor;
import myblog.blog.article.domain.Article;
import myblog.blog.article.dto.ArticleForMainView;
import myblog.blog.article.dto.NewArticleDto;
import myblog.blog.article.repository.ArticleRepository;
import myblog.blog.category.service.CategoryService;
import myblog.blog.member.doamin.Member;
import myblog.blog.member.repository.MemberRepository;
import myblog.blog.member.service.Oauth2MemberService;
import myblog.blog.tags.service.TagsService;
import org.modelmapper.ModelMapper;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Service
@Transactional
@RequiredArgsConstructor
public class ArticleService {
private final ArticleRepository articleRepository;
private final ArticleRepository
articleRepository;
private final MemberRepository memberRepository;
private final TagsService tagsService;
private final CategoryService categoryService;
private final Oauth2MemberService memberService;
private final ModelMapper modelMapper;
public Long writeArticle(NewArticleDto articleDto) {
Article newArticle = createNewArticleFrom(articleDto);
articleRepository.save(newArticle);
tagsService.createNewTagsAndArticleTagList(articleDto.getTags(), newArticle);
return newArticle.getId();
}
public List<ArticleForMainView> getPopularArticles() {
List<Article> top6ByOrderByHitDesc = articleRepository.findTop6ByOrderByHitDesc();
List<ArticleForMainView> articles = new ArrayList<>();
for (Article article : top6ByOrderByHitDesc) {
articles.add(modelMapper.map(article, ArticleForMainView.class));
}
return articles;
}
public Slice<ArticleForMainView> getRecentArticles(int page) {
return articleRepository.findByOrderByCreatedDateDesc(PageRequest.of(page, 5))
.map(article -> modelMapper.map(article, ArticleForMainView.class));
}
private Article createNewArticleFrom(NewArticleDto articleDto) {
Member member =
memberRepository.findById(articleDto.getMemberId()).orElseThrow(() -> {
@@ -33,7 +76,13 @@ public class ArticleService {
.title(articleDto.getTitle())
.content(articleDto.getContent())
.toc(articleDto.getToc())
.thumbnailUrl(articleDto.getThumbnailUrl())
.category(categoryService.findCategory(articleDto.getCategory()))
.member(member)
.build();
}
/*--------------------------------------------------------------------------------------------*/
}

View File

@@ -0,0 +1,13 @@
package myblog.blog.base.config;
import org.modelmapper.ModelMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AppConfig {
@Bean
public ModelMapper modelMapper(){ return new ModelMapper();}
}

View File

@@ -10,6 +10,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
@Configuration
@EnableWebSecurity
@@ -43,6 +44,9 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
.logoutSuccessUrl("/")
.deleteCookies("JSESSIONID","remember-me")
.and().csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.and()
.oauth2Login()
@@ -51,6 +55,8 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
.userInfoEndpoint()
.userService(oauth2MemberService)
;
}
}

View File

@@ -0,0 +1,51 @@
package myblog.blog.category.domain;
import lombok.Builder;
import lombok.Getter;
import myblog.blog.article.domain.Article;
import myblog.blog.base.domain.BasicEntity;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@SequenceGenerator(
name = "CATEGORY_SEQ_GENERATOR",
sequenceName = "CATEGORY_SEQ",
initialValue = 1, allocationSize = 50)
public class Category extends BasicEntity {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "CATEGORY_SEQ_GENERATOR")
@Column(name = "category_id")
private Long id;
private String title;
@OneToMany(mappedBy = "category")
private List<Article> articles = new ArrayList<>();
@Column(nullable = false)
private int tier;
// 셀프조인
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parents_id")
private Category parents;
@OneToMany(mappedBy = "parents")
private List<Category> child = new ArrayList<>();
@Builder
public Category(String title, Category parents, int tier) {
this.title = title;
this.parents = parents;
this.tier = tier;
}
protected Category() {
}
}

View File

@@ -0,0 +1,14 @@
package myblog.blog.category.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class CategoryCountForRepository {
private String title;
private int tier;
private int count;
}

View File

@@ -0,0 +1,53 @@
package myblog.blog.category.dto;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@Getter
@Setter
public class CategoryForMainView {
private int count;
private String title;
private List<CategoryForMainView> categoryTCountList = new ArrayList<>();
public static CategoryForMainView createCategory(List<CategoryCountForRepository> crList) {
Collections.reverse(crList);
return recursBuilding(0, crList);
}
private static CategoryForMainView recursBuilding(int d, List<CategoryCountForRepository> crList) {
CategoryForMainView categoryForMainView = new CategoryForMainView();
while (!crList.isEmpty()) {
CategoryCountForRepository cSource = crList.get(0);
if (cSource.getTier() == d) {
if(categoryForMainView.getTitle() != null
&& categoryForMainView.getTitle() != cSource.getTitle()){
return categoryForMainView;
}
categoryForMainView.setTitle(cSource.getTitle());
categoryForMainView.setCount(cSource.getCount());
crList.remove(0);
} else if (cSource.getTier() > d) {
CategoryForMainView sub = recursBuilding(d + 1, crList);
categoryForMainView.getCategoryTCountList().add(sub);
} else {
return categoryForMainView;
}
}
return categoryForMainView;
}
}

View File

@@ -0,0 +1,11 @@
package myblog.blog.category.repository;
import myblog.blog.category.domain.Category;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CategoryRepository extends JpaRepository<Category, Long> {
Category findByTitle(String title);
}

View File

@@ -0,0 +1,27 @@
package myblog.blog.category.repository;
import myblog.blog.category.dto.CategoryCountForRepository;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.springframework.stereotype.Repository;
import java.util.List;
@Mapper
@Repository
public interface NaCategoryRepository {
@Select("select ifnull(f.title,'total') as title,ifnull(tier,0) as tier, count\n" +
"from \n" +
"(select ifnull(child,parent) as title, count\n" +
"from\n" +
"(select c.title 'parent', b.title as 'child' , count(*) as 'count'\n" +
"from article a\n" +
"join category b on (a.category_id = b.category_id)\n" +
"left join category c on (b.parents_id = c.category_id)\n" +
"group by parent, child with rollup) d\n" +
") e\n" +
"left join category f on (e.title = f.title)")
List<CategoryCountForRepository> getCategoryCount();
}

View File

@@ -0,0 +1,47 @@
package myblog.blog.category.service;
import lombok.RequiredArgsConstructor;
import myblog.blog.category.domain.Category;
import myblog.blog.category.dto.CategoryCountForRepository;
import myblog.blog.category.dto.CategoryForMainView;
import myblog.blog.category.repository.CategoryRepository;
import myblog.blog.category.repository.NaCategoryRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class CategoryService {
private final CategoryRepository categoryRepository;
private final NaCategoryRepository naCategoryRepository;
public Long createNewCategory(String title, String parent) {
Category parentCategory = null;
if (parent != null) {
parentCategory = categoryRepository.findByTitle(parent);
}
Category category = Category.builder()
.title(title)
.parents(parentCategory)
.build();
return category.getId();
}
public Category findCategory(String title){
return categoryRepository.findByTitle(title);
}
public CategoryForMainView getCategoryForView(){
List<CategoryCountForRepository> categoryCount = naCategoryRepository.getCategoryCount();
return CategoryForMainView.createCategory(categoryCount);
}
}

View File

@@ -0,0 +1,28 @@
package myblog.blog.img.controller;
import lombok.RequiredArgsConstructor;
import myblog.blog.img.domain.UploadedImg;
import myblog.blog.img.dto.UploadImgDto;
import myblog.blog.img.service.UploadImgService;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
@RestController
@RequiredArgsConstructor
public class UploadImgController {
private final UploadImgService uploadImgService;
@PostMapping("/article/uploadImg")
public @ResponseBody
String imgUpload(@ModelAttribute UploadImgDto uploadImgDto) throws IOException {
return uploadImgService.storeImg(uploadImgDto.getImg());
}
}

View File

@@ -4,7 +4,6 @@ import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class UploadedImg {
private String uploadFileName;

View File

@@ -0,0 +1,13 @@
package myblog.blog.img.dto;
import lombok.Getter;
import lombok.Setter;
import org.springframework.web.multipart.MultipartFile;
@Getter
@Setter
public class UploadImgDto {
private MultipartFile img;
}

View File

@@ -15,19 +15,18 @@ import java.util.UUID;
@Service
@RequiredArgsConstructor
public class ImgService {
public class UploadImgService {
@Value("${git.gitToken}")
private String gitToken;
@Value("${git.imgRepo}")
@Value("${git.imgRepo}")
private String gitRepo;
@Value("${git.imgUrl}")
private String imgUrl;
private final ModelMapper modelMapper;
public UploadedImg storeImg(MultipartFile multipartFile) throws IOException {
public String storeImg(MultipartFile multipartFile) throws IOException {
if (multipartFile.isEmpty()) {
throw new IllegalArgumentException("이미지가 존재하지 않습니다.");
}
@@ -41,7 +40,9 @@ public class ImgService {
repository.createContent().path("img/"+storeFileName)
.content(multipartFile.getBytes()).message("test").branch("main").commit();
return new UploadedImg(originalFilename, storeFileName, imgUrl +storeFileName+"?raw=true");
UploadedImg uploadedImg = new UploadedImg(originalFilename, storeFileName, imgUrl + storeFileName + "?raw=true");
return uploadedImg.getUploadUrl();
}

View File

@@ -1,18 +1,41 @@
package myblog.blog.main;
import lombok.RequiredArgsConstructor;
import myblog.blog.article.domain.Article;
import myblog.blog.article.dto.ArticleForMainView;
import myblog.blog.article.service.ArticleService;
import myblog.blog.category.dto.CategoryForMainView;
import myblog.blog.category.service.CategoryService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.util.List;
@Controller
@RequiredArgsConstructor
public class MainController {
private final ArticleService articleService;
private final CategoryService categoryService;
@GetMapping("/")
public String main() {
public String main(Model model) {
List<ArticleForMainView> popularArticles = articleService.getPopularArticles();
Slice<ArticleForMainView> recentArticles = articleService.getRecentArticles(0);
CategoryForMainView categoryForView = categoryService.getCategoryForView();
model.addAttribute("category",categoryForView);
model.addAttribute("popularArticles", popularArticles);
model.addAttribute("recentArticles",recentArticles);
return "main";
}
}

View File

@@ -1,13 +1,19 @@
package myblog.blog.member.controller;
import lombok.RequiredArgsConstructor;
import myblog.blog.category.dto.CategoryForMainView;
import myblog.blog.category.service.CategoryService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
@RequiredArgsConstructor
public class MemberController {
private final CategoryService categoryService;
@GetMapping("/login")
public String loginFrom(@RequestParam(value = "error",required = false) String error, Model model){
@@ -15,6 +21,11 @@ public class MemberController {
model.addAttribute("errMsg","이미 가입된 이메일입니다.");
}
CategoryForMainView categoryForView = categoryService.getCategoryForView();
model.addAttribute("category",categoryForView);
return "login";
}

View File

@@ -4,7 +4,6 @@ import myblog.blog.member.doamin.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
Member findByUserId(String userId);

View File

@@ -77,7 +77,6 @@ public class Oauth2MemberService extends DefaultOAuth2UserService {
return member;
}
@PostConstruct
public void insertAdmin(){
Member admin = memberRepository.findByEmail(adminEmail);

View File

@@ -0,0 +1,37 @@
package myblog.blog.tags.domain;
import lombok.Builder;
import myblog.blog.article.domain.Article;
import myblog.blog.base.domain.BasicEntity;
import javax.persistence.*;
@Entity
@SequenceGenerator(
name = "ARTICLE_TAG_LIST_SEQ_GENERATOR",
sequenceName = "ARTICLE_TAG_LIST_SEQ",
initialValue = 1, allocationSize = 50)
public class ArticleTagList extends BasicEntity {
@Id
@Column(name = "article_tag_list_id")
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "ARTICLE_TAG_LIST_SEQ_GENERATOR")
private Long id;
@ManyToOne
@JoinColumn(name = "article_id")
private Article article;
@ManyToOne
@JoinColumn(name = "tags_id")
private Tags tags;
@Builder
public ArticleTagList(Article article, Tags tags) {
this.article = article;
this.tags = tags;
}
protected ArticleTagList() {
}
}

View File

@@ -0,0 +1,36 @@
package myblog.blog.tags.domain;
import lombok.Builder;
import lombok.Getter;
import myblog.blog.base.domain.BasicEntity;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@SequenceGenerator(
name = "TAGS_SEQ_GENERATOR",
sequenceName = "TAGS_SEQ",
initialValue = 1, allocationSize = 50)
@Getter
public class Tags extends BasicEntity {
@Id
@Column(name = "tags_id")
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "TAGS_SEQ_GENERATOR")
private Long id;
@Column(unique = true, nullable = false)
private String name;
@OneToMany(mappedBy = "tags")
private List<ArticleTagList> articleTagLists = new ArrayList<>();
@Builder
public Tags(String name) {
this.name = name;
}
protected Tags() {
}
}

View File

@@ -0,0 +1,14 @@
package myblog.blog.tags.dto;
import lombok.Data;
@Data
public class TagsDto {
private String value;
@Override
public String toString() {
return "{ value : " + value + "}";
}
}

View File

@@ -0,0 +1,7 @@
package myblog.blog.tags.repository;
import myblog.blog.tags.domain.ArticleTagList;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ArticleTagListsRepository extends JpaRepository<ArticleTagList, Long> {
}

View File

@@ -0,0 +1,10 @@
package myblog.blog.tags.repository;
import myblog.blog.tags.domain.Tags;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TagsRepository extends JpaRepository<Tags, Long> {
Tags findByName(String name);
}

View File

@@ -0,0 +1,49 @@
package myblog.blog.tags.service;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import lombok.RequiredArgsConstructor;
import myblog.blog.article.domain.Article;
import myblog.blog.article.repository.ArticleRepository;
import myblog.blog.tags.domain.ArticleTagList;
import myblog.blog.tags.domain.Tags;
import myblog.blog.tags.dto.TagsDto;
import myblog.blog.tags.repository.ArticleTagListsRepository;
import myblog.blog.tags.repository.TagsRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Service
@Transactional
@RequiredArgsConstructor
public class TagsService {
private final TagsRepository tagsRepository;
private final ArticleTagListsRepository articleTagListsRepository;
public void createNewTagsAndArticleTagList(String names, Article article) {
Gson gson = new Gson();
ArrayList<Map> tagsDtoArrayList = gson.fromJson(names, ArrayList.class);
for (Map tags : tagsDtoArrayList) {
Tags tag = tagsRepository.findByName(tags.get("value").toString());
if (tag == null) {
tag = tagsRepository.save(Tags.builder().name(tags.get("value").toString()).build());
}
articleTagListsRepository.save(ArticleTagList.builder()
.article(article)
.tags(tag)
.build());
}
}
}

View File

@@ -15,7 +15,7 @@ server:
jpa:
hibernate:
ddl-auto: create
ddl-auto:
properties:
hibernate:
# show_sql: true

View File

@@ -0,0 +1,20 @@
.thumbBox{
position: relative;
display: none;
}
.thumbDelBtn{
position: absolute;
font-size: 60vh;
background: transparent;
border: transparent;
visibility: hidden;
}
.thumbBox:hover .thumbDelBtn{
visibility: visible;
}
.thumbBox:hover .thumbImg{
opacity: 0.5;
}

View File

@@ -205,6 +205,7 @@ body::-webkit-scrollbar-track {
}
.arrow-up {
visibility: hidden;
position: fixed;
bottom: 50px;
right: 50px;

View File

@@ -0,0 +1,14 @@
const arrow = document.getElementById("arrow");
arrow.addEventListener('click', () => {
window.scrollTo(0, 0);
})
window.addEventListener("scroll", () => {
if (scrollY != 0) {
arrow.style.visibility = 'visible';
} else {
arrow.style.visibility = 'hidden';
}
}, {passive: true});

View File

@@ -89,14 +89,13 @@ const editorMobile = new toastui.Editor({
editor.setMarkdown(contents.value);
editorMobile.setMarkdown(contents.value);
function uploadImage(blob) {
let token = getCsrfToken();
let formData = new FormData();
formData.append('img', blob);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/board/uploadImg", false);
xhr.open("POST", "/article/uploadImg", false);
xhr.setRequestHeader("contentType", "multipart/form-data");
xhr.setRequestHeader("X-XSRF-TOKEN", token);
xhr.send(formData);

View File

@@ -0,0 +1,10 @@
function getCookie(str) {
const value = document.cookie.match('(^|;) ?' + str + '=([^;]*)(;|$)');
return value ? value[2] : null;
}
function getCsrfToken() {
let token = getCookie("XSRF-TOKEN");
return token;
}

View File

@@ -0,0 +1,80 @@
let pageNum = 1;
let flag = false;
const container = document.getElementById("infiniteScrollBox");
function InfinityScroll() {
const next = document.getElementById("nextPagination");
const screenHeight = screen.height;
document.addEventListener('scroll', OnScroll, {passive: true})
function OnScroll() {
const fullHeight = container.clientHeight;
const scrollPosition = pageYOffset;
if (fullHeight - screenHeight / 2 <= scrollPosition && !flag) {
flag = true;
makeNextPage();
}
}
}
function makeNextPage() {
const xhr = new XMLHttpRequest();
// 페이지 요청보내기
xhr.open('GET', "/main/article/" + pageNum);
// xhr.setRequestHeader("X-XSRF-TOKEN", token);
xhr.send();
xhr.onload = () => {
if (xhr.readyState === xhr.DONE) {
if (xhr.status === 200 || xhr.status === 201 || xhr.status === 202) {
let list = JSON.parse(xhr.response);
console.log(list);
// 다음 페이지 작성
const nextPage = document.createElement('div');
nextPage.setAttribute("id", "articlePage-" + pageNum++);
// 아티클 개별추가
for (let listElement of list) {
let article = document.createElement('div');
let date = moment(listElement.createdDate).format('YYYY-MM-DD');
let articleHtmlSource = ' ';
articleHtmlSource +=
"<div class=\"card mb-3 recent-card\">\n" +
" <a href=\"#\">\n" +
" <div class=\"row g-0\">\n" +
" <div class=\"col-3\">\n" +
" <div class=\"ratio ratio-1x1\" style=\"background-image: url("+listElement.thumbnailUrl+"); background-size: cover;\"></div>\n" +
" </div>\n" +
" <div class=\"col-9 row row-cols-1 align-self-center\">\n" +
" <h3 class=\"card-title col mb-3 text-truncate\">"+listElement.title+"</h3>\n" +
" <p class=\"d-none d-md-block col recent-card-text\" >"+listElement.content+"</p>\n" +
" <p class=\"col mb-0\"><small class=\"text-muted\">"+date+"</small></p>\n" +
" </div>\n" +
" </div>\n" +
" </a>\n" +
"</div>"
article.innerHTML = articleHtmlSource;
nextPage.appendChild(article);
}
container.appendChild(nextPage);
flag = false;
} else {
console.error(xhr.response);
}
}
}
}
InfinityScroll()

View File

@@ -0,0 +1,14 @@
const input = document.querySelector('input[name="tags"]');
var whitelist = ["Spring","Java","Spring Boot"];
const tagify = new Tagify(input, {
whitelist:whitelist,
maxTags: 10,
dropdown: {
maxItems: 20,
classname: "tags-look",
enabled: 0,
closeOnSelect: false
}
})

View File

@@ -0,0 +1,46 @@
const thumbBox = document.getElementById("thumbBox");
const uploadThumbBtn = document.getElementById("thumbnail");
const thumbDel = document.getElementById("thumbDelBtn");
const previewThumb = document.getElementById("thumbnailPreView");
const thumbUrl = document.getElementById("thumbnailUrl")
function uploadImg(input) {
if(input.files && input.files[0]) {
let token = getCsrfToken();
let formData = new FormData();
formData.append('img', input.files[0]);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/article/uploadImg", false);
xhr.setRequestHeader("contentType", "multipart/form-data");
xhr.setRequestHeader("X-XSRF-TOKEN", token);
xhr.send(formData);
if (xhr.readyState === 4 && xhr.status === 200) {
thumbUrl.value = xhr.response;
const reader = new FileReader();
reader.onload = e => {
previewThumb.src = e.target.result;
}
reader.readAsDataURL(input.files[0])
thumbBox.style.display = ''
} else {
alert("이미지가 정상적으로 업로드되지 못했습니다.")
}
}
}
uploadThumbBtn.addEventListener("change", e => {
uploadImg(e.target);
})
thumbDel.addEventListener("click", () =>{
thumbBox.style.display = 'none';
thumbUrl.value = "";
})

View File

@@ -19,6 +19,11 @@
"prosemirror-view": "^1.18.7"
}
},
"@yaireo/tagify": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/@yaireo/tagify/-/tagify-4.8.1.tgz",
"integrity": "sha512-SK3mN6HPzNzW3rgolvdsv/3m8OX5xdbh6H8oz/fjdLCa0Qt3JoaAQqjsMxaLG575+ywl5jpvsaIj+JB6yi9LUQ=="
},
"bootstrap": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.1.3.tgz",

View File

@@ -10,6 +10,7 @@
"license": "ISC",
"dependencies": {
"@toast-ui/editor": "^3.1.1",
"@yaireo/tagify": "^4.8.1",
"bootstrap": "^5.1.3"
}
}

View File

@@ -38,7 +38,9 @@
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css"/>
<link rel="stylesheet" href="/node_modules/bootstrap/dist/css/bootstrap.min.css"/>
<link rel="stylesheet" href="/node_modules/@yaireo/tagify/dist/tagify.css"/>
<link rel="stylesheet" href="/css/mainCss.css"/>
<link rel="stylesheet" href="/css/articleWrite.css"/>
<script src="https://kit.fontawesome.com/233840a552.js" crossorigin="anonymous"></script>
</head>
@@ -47,38 +49,69 @@
<section>
<div style="margin-bottom: 150px"></div>
<div id="thumbBox" style="display: none">
<div class="card d-flex align-items-center justify-content-center thumbBox">
<img src="" id="thumbnailPreView" class="card-img-top w-100 vh-100 cover thumbImg" alt="">
<button id="thumbDelBtn" class="thumbDelBtn"><i class="far fa-trash-alt "></i></button>
</div>
</div>
<div class="d-xxl-none p-0" style="margin-bottom: 100px"></div>
<div class="container">
<div class="row justify-content-center mt-5 mb-3 g-0">
<div class="d-flex flex-row-reverse">
<label class="btn btn-secondary" for="thumbnail">썸네일 등록</label>
<input type="file" id="thumbnail" name="thumbnail" accept="image/*" class="d-none">
</div>
<form class="" method="post" enctype="multipart/form-data" th:object="${newArticleDto}"
th:action="@{/article/write}" id="writeArticleForm">
<div class="form-group">
<input id="title" name="title" type="text" class="form-control"
placeholder="제목을 입력해주세요" required max="30">
<textarea type="text" name="thumbnailUrl" id="thumbnailUrl" hidden></textarea>
<div class="mt-3 mb-3">
<select id="category" name="category" class="form-select ps-3" aria-label="Select category">
<option selected>카테고리를 선택해주세요</option>
<option value="Java">Java</option>
<option value="Spring">Spring</option>
<option value="Jpa">Jpa</option>
</select>
</div>
<div class="form-group ">
<input id="title" name="title" type="text" class="form-control ps-3"
placeholder="제목을 입력해주세요" required max="30">
</div>
<div class="form-group">
<textarea type="text" name="content" id="content" hidden></textarea>
</div>
<div class="row justify-content-center mt-4 mb-3 g-0">
<label for="editor"></label>
<div class=" d-none d-sm-block">
<div style="background: white" id="editor"></div>
</div>
<div class=" d-block d-sm-none">
<div style="background: white" id="editorMobile"></div>
</div>
</div>
<!-- tools -->
<div class="mt-3">
<input id="tags" type="text" name="tags" placeholder="태그"
class="form-control tagify-outside ps-3" aria-describedby="tagHelp"/>
</div>
</form>
</div>
<!-- -->
<div class="row justify-content-center mb-3 g-0">
<label for="editor"></label>
<div class=" d-none d-sm-block">
<div id="editor"></div>
</div>
<div class=" d-block d-sm-none">
<div id="editorMobile"></div>
</div>
</div>
<div class="row justify-content-evenly mb-5 g-0">
<div class="row justify-content-evenly mt-4 mb-5 g-0">
<button class="btn btn-secondary col-4 d-none d-sm-block" onclick="post()">등 록</button>
<button class="btn btn-secondary col-4 d-block d-sm-none" onclick="postMobile()">등 록</button>
<button class="btn btn-secondary col-4" onclick="javascript:history.back()">취 소</button>
@@ -86,8 +119,12 @@
</div>
<!--scripts-->
<script src="/node_modules/@yaireo/tagify/dist/tagify.min.js"></script>
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
<script src="/js/getCsrf.js"></script>
<script src="/js/editor.js"></script>
<script src="/js/tags.js"></script>
<script src="/js/thumbnail.js"></script>
</section>

View File

@@ -7,6 +7,7 @@
</head>
<body>
<header class="fixed-top d-xxl-none p-0">
<nav class="navbar navbar-light bg-light">
<div class="container-fluid p-0">
@@ -20,8 +21,8 @@
</a>
</div>
<div id="nav-login" sec:authorize="!isAuthenticated()"><a th:href="@{/login}"><i
class="far fa-address-card"></i></a></div>
<div sec:authorize="isAuthenticated()">
class="far fa-address-card pe-2"></i></a></div>
<div sec:authorize="isAuthenticated()" class="pe-2">
<span sec:authorize="isAuthenticated()" th:text="${#authentication.name} + '님'"></span>
<a th:href="@{/article/write}"><span sec:authorize="hasRole('ADMIN')" style="font-size: 21px"><i
class="fas fa-edit"></i></span></a>
@@ -34,91 +35,12 @@
id="offcanvasMenu"
aria-labelledby="offcanvasLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title">BLOG</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body bg-white">
<div href="#" class="d-flex align-items-center pb-3 mb-3 link-dark border-bottom">
<form class="d-flex">
<input class="form-control me-2" type="search" placeholder="검색해" aria-label="Search">
<button class="btn btn-outline-success" type="submit"><i class="fas fa-search"></i></button>
</form>
</div>
<ul class="list-unstyled ps-0">
<li class="mb-1">
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse"
data-bs-target="#home-collapse" aria-expanded="true">
Link
</button>
<div class="collapse show" id="home-collapse">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li><a href="#" class="link-dark rounded">
<i class="fab fa-github" style="font-size: 20px;"></i>&nbsp Github
</a></li>
</ul>
</div>
</li>
<li class="mb-1">
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse"
data-bs-target="#dashboard-collapse1" aria-expanded="true">
카테고리
</button>
<div class="collapse show" id="dashboard-collapse1">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li><a href="#" class="link-dark rounded">카테고리1</a></li>
<li><a href="#" class="link-dark rounded">카테고리2</a></li>
<li><a href="#" class="link-dark rounded">카테고리3</a></li>
</ul>
</div>
</li>
<li class="mb-1">
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse"
data-bs-target="#dashboard-collapse2" aria-expanded="true">
카테고리
</button>
<div class="collapse show" id="dashboard-collapse2">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li><a href="#" class="link-dark rounded">카테고리1</a></li>
<li><a href="#" class="link-dark rounded">카테고리2</a></li>
<li><a href="#" class="link-dark rounded">카테고리3</a></li>
</ul>
</div>
</li>
<li class="mb-1">
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse"
data-bs-target="#dashboard-collapse3" aria-expanded="true">
카테고리
</button>
<div class="collapse show" id="dashboard-collapse3">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li><a href="#" class="link-dark rounded">카테고리1</a></li>
<li><a href="#" class="link-dark rounded">카테고리2</a></li>
<li><a href="#" class="link-dark rounded">카테고리3</a></li>
</ul>
</div>
</li>
<li class="border-top my-3"></li>
<li class="mb-1">
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse"
data-bs-target="#account-collapse" aria-expanded="true">
Account
</button>
<div class="collapse show" id="account-collapse">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li sec:authorize="!isAuthenticated()"><a href="#" th:href="@{/login}"
class="link-dark rounded">로그인</a></li>
<li sec:authorize="isAuthenticated()" th:text="${#authentication.name} + '님 환영합니다'"></li>
<li sec:authorize="isAuthenticated()">
<form method="post" id="logoutOC" name="logoutOC" th:action="@{/logout}">
<a href="#" class="nav-link active" onclick="document.logout.submit()">로그아웃</a>
</form>
</li>
</ul>
</div>
</li>
</ul>
<div th:replace="layout/sideBar.html :: sideBar"></div>
</div>
</aside>
@@ -133,104 +55,11 @@
<div class="p-4 sidebar-inner">
<div class="m-4 sidebar-header">
<a href="#">
<h5 class="text-black-50">BLOG</h5>
</a>
<a href="#">
<h5 class="text-black-50">ABOUT ME</h5>
</a>
</div>
<div href="#" class="d-flex align-items-center pb-3 mb-3 link-dark border-bottom">
<form class="d-flex">
<input class="form-control me-2" type="search" placeholder="검색해" aria-label="Search">
<button class="btn btn-outline-success" type="submit"><i class="fas fa-search"></i></button>
</form>
</div>
<ul class="list-unstyled ps-0">
<li class="mb-1">
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse"
data-bs-target="#home-collapse-xl" aria-expanded="true">
Link
</button>
<div class="collapse show" id="home-collapse-xl">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li><a href="#" class="link-dark rounded">
<i class="fab fa-github" style="font-size: 20px;"></i>&nbsp Github
</a></li>
</ul>
</div>
</li>
<li class="mb-1">
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse"
data-bs-target="#dashboard-collapse1-xl" aria-expanded="true">
카테고리
</button>
<div class="collapse show" id="dashboard-collapse1-xl">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li><a href="#" class="link-dark rounded">카테고리1</a></li>
<li><a href="#" class="link-dark rounded">카테고리2</a></li>
<li><a href="#" class="link-dark rounded">카테고리3</a></li>
</ul>
</div>
</li>
<li class="mb-1">
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse"
data-bs-target="#dashboard-collapse2-xl" aria-expanded="true">
카테고리
</button>
<div class="collapse show" id="dashboard-collapse2-xl">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li><a href="#" class="link-dark rounded">카테고리1</a></li>
<li><a href="#" class="link-dark rounded">카테고리2</a></li>
<li><a href="#" class="link-dark rounded">카테고리3</a></li>
</ul>
</div>
</li>
<li class="mb-1">
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse"
data-bs-target="#dashboard-collapse3-xl" aria-expanded="true">
카테고리
</button>
<div class="collapse show" id="dashboard-collapse3-xl">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li><a href="#" class="link-dark rounded">카테고리1</a></li>
<li><a href="#" class="link-dark rounded">카테고리2</a></li>
<li><a href="#" class="link-dark rounded">카테고리3</a></li>
</ul>
</div>
</li>
<li class="border-top my-3"></li>
<li class="mb-1">
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse"
data-bs-target="#account-collapse-xl" aria-expanded="true">
Account
</button>
<div class="collapse show" id="account-collapse-xl">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li sec:authorize="!isAuthenticated()"><a th:href="@{/login}"
class="link-dark rounded">로그인</a></li>
<li sec:authorize="isAuthenticated()" th:text="${#authentication.name} + '님 환영합니다'"></li>
<li sec:authorize="isAuthenticated()">
<div class="link-dark rounded">
<form method="post" id="logout" name="logout" th:action="@{/logout}">
<a href="#" class="nav-link active" onclick="document.logout.submit()">로그아웃</a>
</form>
</div>
</li>
</ul>
</div>
</li>
</ul>
<div th:replace="layout/sideBar.html :: sideBar"></div>
</div>
</div>
<!-- sidebar e -->
<!-- xxl dummy -->
<div class="bg-white col-xxl-2 d-none d-xxl-block p-0">
</div>
@@ -241,25 +70,22 @@
<section th:replace="${content}"></section>
<button class="arrow-up">
<button id="arrow" class="arrow-up">
<i class="fas fa-arrow-up"></i>
</button>
<script src="/node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="/js/arrow.js"></script>
<!-- scripts e -->
<!-- sections e -->
<!-- sections e -->
<footer class="footer bg-light">
<div class="container text-center p-2">
<h5><span class="text-muted">Copyright ©Jinia</span></h5>
</div>
</footer>
</div>
</section>
<!-- body e -->
</body>
</html>

View File

@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<div th:fragment="sideBar">
<div class="m-4 sidebar-header">
<a th:href="@{/}">
<h5 class="text-black-50">BLOG</h5>
</a>
<a href="#">
<h5 class="text-black-50">ABOUT ME</h5>
</a>
<a sec:authorize="hasRole('ADMIN')" th:href="@{/article/write}" href="#">
<h5 class="text-black-50">WRITE ARTICLE</h5>
</a>
</div>
<div href="#" class="d-flex align-items-center pb-3 mb-3 link-dark border-bottom">
<form class="d-flex">
<input class="form-control me-2" type="search" placeholder="검색해" aria-label="Search">
<button class="btn btn-outline-success" type="submit"><i class="fas fa-search"></i></button>
</form>
</div>
<ul class="list-unstyled ps-0">
<li class="mb-1">
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse"
data-bs-target="#collapse-link" aria-expanded="true">
Link
</button>
<div class="collapse show" id="collapse-link">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li><a href="https://github.com/jinia91" class="link-dark rounded">
<i class="fab fa-github" style="font-size: 20px;"></i>&nbsp Github
</a></li>
</ul>
</div>
</li>
<li class="mb-1" th:text="|전체글(${category.getCount()})|"></li>
<li class="mb-1" th:each="superCategory : ${category.getCategoryTCountList()}">
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse"
th:data-bs-target="|#collapse-${superCategory.getTitle()}-sub|"
aria-expanded="true"
th:text="|${superCategory.getTitle()}(${superCategory.getCount()})|">
</button>
<div class="collapse show" th:id="|collapse-${superCategory.getTitle()}-sub|">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small" th:each="subCategory : ${superCategory.getCategoryTCountList()}">
<li><a href="#" class="link-dark rounded" th:text="|${subCategory.getTitle()}(${subCategory.getCount()})|">카테고리1</a></li>
</ul>
</div>
</li>
<li class="border-top my-3"></li>
<li class="mb-1">
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse"
data-bs-target="#account-collapse-xl" aria-expanded="true">
Account
</button>
<div class="collapse show" id="account-collapse-xl">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li sec:authorize="!isAuthenticated()"><a th:href="@{/login}"
class="link-dark rounded">로그인</a></li>
<li sec:authorize="isAuthenticated()" th:text="${#authentication.name} + '님 환영합니다'"></li>
<li sec:authorize="isAuthenticated()">
<div class="link-dark rounded">
<form method="post" id="logout" name="logout" th:action="@{/logout}">
<a href="#" class="nav-link active" onclick="document.logout.submit()">로그아웃</a>
</form>
</div>
</li>
</ul>
</div>
</li>
</ul>
</div>
</html>

View File

@@ -0,0 +1,368 @@
<!DOCTYPE html>
<html th:replace="~{layout/layout.html :: layout(~{::head}, ~{::section})}"
lang="ko" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Jinia's Log</title>
<!-- SEO -->
<meta name="description" content=""/>
<meta name="keyword" content=""/>
<meta name="author" content="jinia"/>
<meta name="viewport" content="width=device-width, user-scalable = no, initial-scale=1.0"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<!-- OPEN GRAPH(FACEBOOK, LINKEDIN) -->
<meta property="og:type" content=""/>
<meta property="og:description" content=""/>
<meta property="og:title" content=""/>
<meta property="og:image" content=""/>
<meta property="og:url" content=""/>
<meta property="og:site_name" content=""/>
<!-- twitter -->
<meta property="twitter:card" content=""/>
<meta property="twitter:title" content=""/>
<meta property="twitter:description" content=""/>
<meta property="twitter:image" content=""/>
<meta property="twitter:url" content=""/>
<meta property="twitter:creator" content=""/>
<link rel="icon" href=""/>
<link rel="apple-touch-icon" href=""/>
<link rel="short icon" type="image/x-icon" href=""/>
<!-- CSS RESET -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css"/>
<link rel="stylesheet" href="/node_modules/bootstrap/dist/css/bootstrap.min.css"/>
<link rel="stylesheet" href="/css/mainCss.css"/>
<link rel="stylesheet" href="/css/login.css"/>
<script src="https://kit.fontawesome.com/233840a552.js" crossorigin="anonymous"></script>
</head>
<body>
<section>
<div class="main">
<div id="carouselExampleCaptions" class="carousel slide" data-bs-ride="carousel">
<div class="carousel-indicators">
<button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="0" class="active"
aria-current="true" aria-label="Slide 0"></button>
<button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="1"
aria-label="Slide 1"></button>
<button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="2"
aria-label="Slide 2"></button>
<button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="3"
aria-label="Slide 3"></button>
<button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="4"
aria-label="Slide 4"></button>
<button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="5"
aria-label="Slide 5"></button>
</div>
<div class="carousel-inner">
<div class="carousel-item active">
<a href="#">
<img src="https://cdn.pixabay.com/photo/2020/11/08/13/28/tree-5723734_1280.jpg"
class="w-100 vh-100 cover" alt="...">
<div class="card-img-overlay text-white text-center row justify-content-center align-content-center">
<h3 class="">첫번째 이미지</h3>
<p class="text-truncate w-50">블로그 아티클 헤으응</p>
</div>
</a>
</div>
<div class="carousel-item">
<a href="#">
<img src="https://cdn.pixabay.com/photo/2021/09/27/14/39/paris-6661136_1280.jpg"
class="w-100 vh-100 cover" alt="...">
<div class="card-img-overlay text-white text-center row justify-content-center align-content-center">
<h3>두번째 이미지</h3>
<p class="text-truncate w-50">블로그 아티클 헤으응2</p>
</div>
</a>
</div>
<div class="carousel-item">
<a href="#">
<img src="https://cdn.pixabay.com/photo/2021/10/23/16/31/italy-6735340_1280.jpg"
class="w-100 vh-100 cover" alt="...">
<div class="card-img-overlay text-white text-center row justify-content-center align-content-center">
<h3>세번째 이미지</h3>
<p class="text-truncate w-50">블로그 아티클 헤으응3</p>
</div>
</a>
</div>
<div class="carousel-item">
<a href="#">
<img src="https://cdn.pixabay.com/photo/2021/10/13/15/09/water-6706894_1280.jpg"
class="w-100 vh-100 cover" alt="...">
<div class="card-img-overlay text-white text-center row justify-content-center align-content-center">
<h3>네번째 이미지</h3>
<p class="text-truncate w-50">블로그 아티클
헤으응31!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!헤으응31!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!헤으응31!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
</p>
</div>
</a>
</div>
<div class="carousel-item">
<a href="#">
<img
src="https://media.istockphoto.com/photos/matrix-background-with-the-green-numbers-picture-id539244598"
class="w-100 vh-100 cover" alt="...">
<div class="card-img-overlay text-white text-center row justify-content-center align-content-center">
<h3>다섯번째 이미지</h3>
<p class="text-truncate w-50">블로그 아티클 헤으응3</p>
</div>
</a>
</div>
<div class="carousel-item">
<a href="#">
<img src="https://cdn.pixabay.com/photo/2021/01/01/21/31/halloween-5880068_1280.jpg"
class="w-100 vh-100 cover" alt="...">
<div class="card-img-overlay text-white text-center row justify-content-center align-content-center">
<h3>여섯번째 이미지</h3>
<p class="text-truncate">블로그 아티클 헤으응3</p>
</div>
</a>
</div>
</div>
<button class="carousel-control-prev" type="button" data-bs-target="#carouselExampleCaptions"
data-bs-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="visually-hidden">Previous</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#carouselExampleCaptions"
data-bs-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="visually-hidden">Next</span>
</button>
</div>
</div>
<div class="mt-5 pt-2 popular-card-section">
<div class="cards-container container p-0">
<div class=" row g-0 row-cols-2 row-cols-md-3">
<div class="col g-1 g-md-4 ">
<a href="#">
<div class=" card ratio ratio-1x1 popular-card " style="background-image: url(https://cdn.pixabay.com/photo/2020/11/08/13/28/tree-5723734_1280.jpg);
background-size: cover;">
<div class="card-description">
<p>타이틀</p>
</div>
<div class="card-des-overlay">설명</div>
</div>
</a>
</div>
<div class="col g-1 g-md-4">
<a href="#">
<div class="card ratio ratio-1x1 popular-card " style="background-image: url(https://cdn.pixabay.com/photo/2021/09/27/14/39/paris-6661136_1280.jpg);
background-size: cover;">
<div class="card-description">
<p>타이틀</p>
</div>
<div class="card-des-overlay">설명</div>
</div>
</a>
</div>
<div class="col g-1 g-md-4">
<a href="#">
<div class="card ratio ratio-1x1 popular-card " style="background-image: url(https://cdn.pixabay.com/photo/2021/10/23/16/31/italy-6735340_1280.jpg);
background-size: cover;">
<div class="card-description">
<p>타이틀</p>
</div>
<div class="card-des-overlay">설명</div>
</div>
</a>
</div>
<div class="col g-1 g-md-4">
<a href="#">
<div class="card ratio ratio-1x1 popular-card " style="background-image: url(https://cdn.pixabay.com/photo/2021/10/13/15/09/water-6706894_1280.jpg);
background-size: cover;">
<div class="card-description">
<p>타이틀</p>
</div>
<div class="card-des-overlay">설명</div>
</div>
</a>
</div>
<div class="col g-1 g-md-4">
<a href="#">
<div class="card ratio ratio-1x1 popular-card " style="background-image: url(https://media.istockphoto.com/photos/matrix-background-with-the-green-numbers-picture-id539244598);
background-size: cover;">
<div class="card-description">
<p>타이틀</p>
</div>
<div class="card-des-overlay">설명</div>
</div>
</a>
</div>
<div class="col g-1 g-md-4">
<a href="#">
<div class="card ratio ratio-1x1 popular-card " style="background-image: url(https://cdn.pixabay.com/photo/2021/01/01/21/31/halloween-5880068_1280.jpg);
background-size: cover;">
<div class="card-description">
<p>타이틀</p>
</div>
<div class="card-des-overlay">설명</div>
</div>
</a>
</div>
</div>
</div>
</div>
<hr class="py-1 mt-5" style="color: rgb(212, 200, 184);">
<div class="recent-cards mt-5">
<div class="cards-container container p-0">
<h1 class="text-center">최신 포스팅</h1>
<hr>
<div class="card mb-3 recent-card">
<a href="#">
<div class="row g-0">
<div class="col-3">
<div class="ratio ratio-1x1" style="background-image: url(https://cdn.pixabay.com/photo/2021/09/27/14/39/paris-6661136_1280.jpg);
background-size: cover;"></div>
</div>
<div class="col-9 row row-cols-1 align-self-center">
<h3 class="card-title col mb-3 text-truncate">글 제목</h3>
<p class="d-none d-md-block col recent-card-text">대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용대충
존나
내용대충 존나 긴대충 긴대충 긴대충 긴대충 긴대충 긴대충 긴대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용</p>
<p class="col mb-0"><small class="text-muted">Last updated 3 mins ago</small></p>
</div>
</div>
</a>
</div>
<div class="card mb-3 recent-card">
<a href="#">
<div class="row g-0">
<div class="col-3">
<div class="ratio ratio-1x1" style="background-image: url(https://media.istockphoto.com/photos/matrix-background-with-the-green-numbers-picture-id539244598);
background-size: cover;"></div>
</div>
<div class="col-9 row row-cols-1 align-self-center">
<h3 class="card-title col mb-3 text-truncate">글 제목</h3>
<p class="d-none d-md-block col recent-card-text">대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용대충
존나
내용대충 존나 긴
내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충
존나 긴 내용대충 존나 긴
내용대충
존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용
</p>
<p class="col mb-0"><small class="text-muted">Last updated 3 mins ago</small></p>
</div>
</div>
</a>
</div>
<div class="card mb-3 recent-card">
<a href="#">
<div class="row g-0">
<div class="col-3">
<div class="ratio ratio-1x1" style="background-image: url(https://media.istockphoto.com/photos/matrix-background-with-the-green-numbers-picture-id539244598);
background-size: cover;"></div>
</div>
<div class="col-9 row row-cols-1 align-self-center">
<h3 class="card-title col mb-3 text-truncate">글 제목</h3>
<p class="d-none d-md-block col recent-card-text">대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용대충
존나
내용대충 존나 긴
내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충
존나 긴 내용대충 존나 긴
내용대충
존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용
</p>
<p class="col mb-0"><small class="text-muted">Last updated 3 mins ago</small></p>
</div>
</div>
</a>
</div>
<div class="card mb-3 recent-card">
<a href="#">
<div class="row g-0">
<div class="col-3">
<div class="ratio ratio-1x1" style="background-image: url(https://media.istockphoto.com/photos/matrix-background-with-the-green-numbers-picture-id539244598);
background-size: cover;"></div>
</div>
<div class="col-9 row row-cols-1 align-self-center">
<h3 class="card-title col mb-3 text-truncate">글 제목</h3>
<p class="d-none d-md-block col recent-card-text">대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용대충
존나
내용대충 존나 긴
내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충
존나 긴 내용대충 존나 긴
내용대충
존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용
</p>
<p class="col mb-0"><small class="text-muted">Last updated 3 mins ago</small></p>
</div>
</div>
</a>
</div>
<div class="card mb-3 recent-card">
<a href="#">
<div class="row g-0">
<div class="col-3">
<div class="ratio ratio-1x1" style="background-image: url(https://cdn.pixabay.com/photo/2021/01/01/21/31/halloween-5880068_1280.jpg);
background-size: cover;"></div>
</div>
<div class="col-9 row row-cols-1 align-self-center">
<h3 class="card-title col mb-3 text-truncate">글 제목</h3>
<p class="d-none d-md-block col recent-card-text">대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용대충
존나
내용대충 존나 긴
내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충
존나 긴 내용대충 존나 긴
내용대충
존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용
</p>
<p class="col mb-0"><small class="text-muted update">Last updated 3 mins ago</small></p>
</div>
</div>
</a>
</div>
</div>
</div>
</section>
</body>
</html>

View File

@@ -51,86 +51,31 @@
<div id="carouselExampleCaptions" class="carousel slide" data-bs-ride="carousel">
<div class="carousel-indicators">
<button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="0" class="active"
aria-current="true" aria-label="Slide 0"></button>
<button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="1"
aria-label="Slide 1"></button>
<button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="2"
aria-label="Slide 2"></button>
<button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="3"
aria-label="Slide 3"></button>
<button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="4"
aria-label="Slide 4"></button>
<button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="5"
aria-label="Slide 5"></button>
<div th:each="article,num : ${popularArticles}">
<button th:if="${num.index}==0"
th:data-bs-slide-to="${num.index}"
th:aria-label="|Slide ${num.index}|"
type="button" data-bs-target="#carouselExampleCaptions" class="active"
aria-current="true"></button>
<button type="button" data-bs-target="#carouselExampleCaptions"
th:if="${num.index}!=0"
th:aria-label="|Slide ${num.index}|"
th:data-bs-slide-to="${num.index}"></button>
</div>
</div>
<div class="carousel-inner">
<div class="carousel-item active">
<div class="carousel-item" th:each="article,num : ${popularArticles}" th:classappend="${num.index}==0? active">
<a href="#">
<img src="https://cdn.pixabay.com/photo/2020/11/08/13/28/tree-5723734_1280.jpg"
<img th:src="${article.getThumbnailUrl()}"
class="w-100 vh-100 cover" alt="...">
<div class="card-img-overlay text-white text-center row justify-content-center align-content-center">
<h3 class="">첫번째 이미지</h3>
<p class="text-truncate w-50">블로그 아티클 헤으응</p>
<h3 class="" th:text="${article.getTitle()}">첫번째 이미지</h3>
<p class="text-truncate w-50" th:text="${article.getContent()}">블로그 아티클 헤으응</p>
</div>
</a>
</div>
<div class="carousel-item">
<a href="#">
<img src="https://cdn.pixabay.com/photo/2021/09/27/14/39/paris-6661136_1280.jpg"
class="w-100 vh-100 cover" alt="...">
<div class="card-img-overlay text-white text-center row justify-content-center align-content-center">
<h3>두번째 이미지</h3>
<p class="text-truncate w-50">블로그 아티클 헤으응2</p>
</div>
</a>
</div>
<div class="carousel-item">
<a href="#">
<img src="https://cdn.pixabay.com/photo/2021/10/23/16/31/italy-6735340_1280.jpg"
class="w-100 vh-100 cover" alt="...">
<div class="card-img-overlay text-white text-center row justify-content-center align-content-center">
<h3>세번째 이미지</h3>
<p class="text-truncate w-50">블로그 아티클 헤으응3</p>
</div>
</a>
</div>
<div class="carousel-item">
<a href="#">
<img src="https://cdn.pixabay.com/photo/2021/10/13/15/09/water-6706894_1280.jpg"
class="w-100 vh-100 cover" alt="...">
<div class="card-img-overlay text-white text-center row justify-content-center align-content-center">
<h3>네번째 이미지</h3>
<p class="text-truncate w-50">블로그 아티클
헤으응31!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!헤으응31!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!헤으응31!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
</p>
</div>
</a>
</div>
<div class="carousel-item">
<a href="#">
<img
src="https://media.istockphoto.com/photos/matrix-background-with-the-green-numbers-picture-id539244598"
class="w-100 vh-100 cover" alt="...">
<div class="card-img-overlay text-white text-center row justify-content-center align-content-center">
<h3>다섯번째 이미지</h3>
<p class="text-truncate w-50">블로그 아티클 헤으응3</p>
</div>
</a>
</div>
<div class="carousel-item">
<a href="#">
<img src="https://cdn.pixabay.com/photo/2021/01/01/21/31/halloween-5880068_1280.jpg"
class="w-100 vh-100 cover" alt="...">
<div class="card-img-overlay text-white text-center row justify-content-center align-content-center">
<h3>여섯번째 이미지</h3>
<p class="text-truncate">블로그 아티클 헤으응3</p>
</div>
</a>
</div>
</div>
@@ -150,218 +95,58 @@
<div class="mt-5 pt-2 popular-card-section">
<div class="cards-container container p-0">
<div class=" row g-0 row-cols-2 row-cols-md-3">
<div class="col g-1 g-md-4 ">
<div class="col g-1 g-md-4" th:each="article,num : ${popularArticles}">
<a href="#">
<div class=" card ratio ratio-1x1 popular-card " style="background-image: url(https://cdn.pixabay.com/photo/2020/11/08/13/28/tree-5723734_1280.jpg);
background-size: cover;">
<div class=" card ratio ratio-1x1 popular-card "
th:style="|background-image: url(${article.getThumbnailUrl()}); background-size: cover;|">
<div class="card-description">
<p>타이틀</p>
<p th:text="${article.getTitle()}">타이틀</p>
</div>
<div class="card-des-overlay">설명</div>
</div>
</a>
</div>
<div class="col g-1 g-md-4">
<a href="#">
<div class="card ratio ratio-1x1 popular-card " style="background-image: url(https://cdn.pixabay.com/photo/2021/09/27/14/39/paris-6661136_1280.jpg);
background-size: cover;">
<div class="card-description">
<p>타이틀</p>
</div>
<div class="card-des-overlay">설명</div>
</div>
</a>
</div>
<div class="col g-1 g-md-4">
<a href="#">
<div class="card ratio ratio-1x1 popular-card " style="background-image: url(https://cdn.pixabay.com/photo/2021/10/23/16/31/italy-6735340_1280.jpg);
background-size: cover;">
<div class="card-description">
<p>타이틀</p>
</div>
<div class="card-des-overlay">설명</div>
</div>
</a>
</div>
<div class="col g-1 g-md-4">
<a href="#">
<div class="card ratio ratio-1x1 popular-card " style="background-image: url(https://cdn.pixabay.com/photo/2021/10/13/15/09/water-6706894_1280.jpg);
background-size: cover;">
<div class="card-description">
<p>타이틀</p>
</div>
<div class="card-des-overlay">설명</div>
</div>
</a>
</div>
<div class="col g-1 g-md-4">
<a href="#">
<div class="card ratio ratio-1x1 popular-card " style="background-image: url(https://media.istockphoto.com/photos/matrix-background-with-the-green-numbers-picture-id539244598);
background-size: cover;">
<div class="card-description">
<p>타이틀</p>
</div>
<div class="card-des-overlay">설명</div>
</div>
</a>
</div>
<div class="col g-1 g-md-4">
<a href="#">
<div class="card ratio ratio-1x1 popular-card " style="background-image: url(https://cdn.pixabay.com/photo/2021/01/01/21/31/halloween-5880068_1280.jpg);
background-size: cover;">
<div class="card-description">
<p>타이틀</p>
</div>
<div class="card-des-overlay">설명</div>
<div class="card-des-overlay" th:text="${article.getContent()}">설명</div>
</div>
</a>
</div>
</div>
</div>
</div>
<hr class="py-1 mt-5" style="color: rgb(212, 200, 184);">
<div class="recent-cards mt-5">
<div class="cards-container container p-0">
<div class="cards-container container p-0" id="infiniteScrollBox">
<h1 class="text-center">최신 포스팅</h1>
<hr>
<div class="card mb-3 recent-card">
<a href="#">
<div class="row g-0">
<div class="col-3">
<div class="ratio ratio-1x1" style="background-image: url(https://cdn.pixabay.com/photo/2021/09/27/14/39/paris-6661136_1280.jpg);
background-size: cover;"></div>
<div id="articlePage-0">
<div class="card mb-3 recent-card" th:each="article,num :${recentArticles.getContent()}">
<a href="#">
<div class="row g-0">
<div class="col-3">
<div class="ratio ratio-1x1" th:style="|background-image: url(${article.getThumbnailUrl()}); background-size: cover;|"></div>
</div>
<div class="col-9 row row-cols-1 align-self-center">
<h3 class="card-title col mb-3 text-truncate" th:text="${article.getTitle()}">글 제목</h3>
<p class="d-none d-md-block col recent-card-text" th:text="${article.getContent()}">내용</p>
<p class="col mb-0"><small class="text-muted" th:text="|작성일 : ${#temporals.format(article.getCreatedDate(), 'yyyy-MM-dd HH')}|"></small></p>
</div>
</div>
<div class="col-9 row row-cols-1 align-self-center">
<h3 class="card-title col mb-3 text-truncate">글 제목</h3>
<p class="d-none d-md-block col recent-card-text">대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용대충
존나
내용대충 존나 긴대충 긴대충 긴대충 긴대충 긴대충 긴대충 긴대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용</p>
<p class="col mb-0"><small class="text-muted">Last updated 3 mins ago</small></p>
</div>
</div>
</a>
</a>
</div>
</div>
<div class="card mb-3 recent-card">
<a href="#">
<div class="row g-0">
<div class="col-3">
<div class="ratio ratio-1x1" style="background-image: url(https://media.istockphoto.com/photos/matrix-background-with-the-green-numbers-picture-id539244598);
background-size: cover;"></div>
</div>
<div class="col-9 row row-cols-1 align-self-center">
<h3 class="card-title col mb-3 text-truncate">글 제목</h3>
<p class="d-none d-md-block col recent-card-text">대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용대충
존나
내용대충 존나 긴
내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충
존나 긴 내용대충 존나 긴
내용대충
존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용
</p>
<p class="col mb-0"><small class="text-muted">Last updated 3 mins ago</small></p>
</div>
</div>
</a>
</div>
<div class="card mb-3 recent-card">
<a href="#">
<div class="row g-0">
<div class="col-3">
<div class="ratio ratio-1x1" style="background-image: url(https://media.istockphoto.com/photos/matrix-background-with-the-green-numbers-picture-id539244598);
background-size: cover;"></div>
</div>
<div class="col-9 row row-cols-1 align-self-center">
<h3 class="card-title col mb-3 text-truncate">글 제목</h3>
<p class="d-none d-md-block col recent-card-text">대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용대충
존나
내용대충 존나 긴
내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충
존나 긴 내용대충 존나 긴
내용대충
존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용
</p>
<p class="col mb-0"><small class="text-muted">Last updated 3 mins ago</small></p>
</div>
</div>
</a>
</div>
<div class="card mb-3 recent-card">
<a href="#">
<div class="row g-0">
<div class="col-3">
<div class="ratio ratio-1x1" style="background-image: url(https://media.istockphoto.com/photos/matrix-background-with-the-green-numbers-picture-id539244598);
background-size: cover;"></div>
</div>
<div class="col-9 row row-cols-1 align-self-center">
<h3 class="card-title col mb-3 text-truncate">글 제목</h3>
<p class="d-none d-md-block col recent-card-text">대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용대충
존나
내용대충 존나 긴
내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충
존나 긴 내용대충 존나 긴
내용대충
존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용
</p>
<p class="col mb-0"><small class="text-muted">Last updated 3 mins ago</small></p>
</div>
</div>
</a>
</div>
<div class="card mb-3 recent-card">
<a href="#">
<div class="row g-0">
<div class="col-3">
<div class="ratio ratio-1x1" style="background-image: url(https://cdn.pixabay.com/photo/2021/01/01/21/31/halloween-5880068_1280.jpg);
background-size: cover;"></div>
</div>
<div class="col-9 row row-cols-1 align-self-center">
<h3 class="card-title col mb-3 text-truncate">글 제목</h3>
<p class="d-none d-md-block col recent-card-text">대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용대충
존나
내용대충 존나 긴
내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충
존나 긴 내용대충 존나 긴
내용대충
존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴 내용대충 존나 긴
내용대충 존나 긴 내용
</p>
<p class="col mb-0"><small class="text-muted update">Last updated 3 mins ago</small></p>
</div>
</div>
</a>
</div>
<div id="nextPagination"></div>
</div>
</div>
<!-- js -->
<script src="/js/infinityScroll.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.0/moment.min.js"></script>
<!-- -->
</section>
</body>

View File

@@ -1,8 +1,16 @@
package myblog.blog.article.service;
import myblog.blog.article.dto.NewArticleDto;
import myblog.blog.article.domain.Article;
import myblog.blog.article.repository.ArticlePagingRepository;
import myblog.blog.article.repository.ArticleRepository;
import org.assertj.core.api.Assertions;
import myblog.blog.category.domain.Category;
import myblog.blog.category.dto.CategoryCountForRepository;
import myblog.blog.category.dto.CategoryForMainView;
import myblog.blog.category.repository.CategoryRepository;
import myblog.blog.category.repository.NaCategoryRepository;
import myblog.blog.category.service.CategoryService;
import myblog.blog.member.repository.MemberRepository;
import myblog.blog.member.service.Oauth2MemberService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@@ -10,6 +18,8 @@ import org.springframework.test.annotation.Rollback;
import org.springframework.transaction.annotation.Transactional;
import javax.persistence.EntityManager;
import java.util.List;
import java.util.UUID;
@SpringBootTest
@@ -23,24 +33,162 @@ class ArticleServiceTest {
ArticleRepository articleRepository;
@Autowired
EntityManager entityManager;
@Autowired
ArticlePagingRepository articlePagingRepository;
@Autowired
Oauth2MemberService memberService;
@Autowired
CategoryService categoryService;
@Autowired
MemberRepository memberRepository;
@Autowired
CategoryRepository categoryRepository;
@Autowired
NaCategoryRepository naCategoryRepository;
// @BeforeEach
void 더미게시글() {
memberService.insertAdmin();
Category ca1 = Category.builder()
.title("CA1")
.tier(1)
.build();
Category category1 = ca1;
Category ca2 = Category.builder()
.title("CA2")
.tier(1)
.build();
Category category2 = ca2;
Category category3 = Category.builder()
.title("Java")
.tier(2)
.parents(ca1)
.build();
Category category4 = Category.builder()
.title("Spring")
.parents(ca1)
.tier(2)
.build();
Category category5 = Category.builder()
.title("Jpa")
.parents(ca2)
.tier(2)
.build();
categoryRepository.save(category1);
categoryRepository.save(category2);
categoryRepository.save(category3);
categoryRepository.save(category4);
categoryRepository.save(category5);
String[] arr = {"Java", "Spring", "Jpa"};
int n = 100;
while (n-- > 0) {
articleRepository.save(Article.builder()
.title(UUID.randomUUID().toString())
.content(String.valueOf(100 - n))
.thumbnailUrl("https://picsum.photos/600/59" + (int) (Math.random() * 10))
.category(categoryService.findCategory(arr[(int) (Math.random() * 3)]))
.member(memberRepository.findById(1L).get())
.build());
}
}
// @Test
// public void 게시글테스트() throws Exception {
// List<Article> popularArticle = articleRepository.findTop6ByOrderByHitDesc();
//
// for (Article article : popularArticle) {
// System.out.println("article.getId() = " + article.getId());
// }
//
// List<Article> top5ByOOrderByCreatedDateDesc = articleRepository.findTop5ByOrderByCreatedDateDesc();
//
// for (Article article : top5ByOOrderByCreatedDateDesc) {
// System.out.println("article.getCreatedDate() = " + article.getCreatedDate());
//
//
// }
// entityManager.clear();
// PageRequest createdDate = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "createdDate"));
//
// Slice<Article> articles = articleRepository.findByOrderByCreatedDateDesc(createdDate);
//
// List<Article> content = articles.getContent();
//
// for (Article article : content) {
// System.out.println("article.getCreatedDate() = " + article.getCreatedDate());
// System.out.println("article.getContent() = " + article.getContent());
// }
//
//
// System.out.println(articles.getNumber());
// System.out.println(articles.getNumberOfElements());
// System.out.println(articles.hasNext());
// }
//
@Test
public void 게시글테스트() throws Exception {
// given
NewArticleDto newArticleDto = new NewArticleDto();
newArticleDto.setTitle("abs");
newArticleDto.setMemberId(1L);
newArticleDto.setToc("df");
newArticleDto.setContent("sdfsf");
public void 더미데이터테스트() throws Exception {
List<CategoryCountForRepository> categoryCountForRepository = naCategoryRepository.getCategoryCount();
// for (CategoryCountForRepository count : categoryCountForRepository) {
//
// System.out.println("tier "+ count.getTier() + count.getTitle() +"(" + count.getCount()+")");
//
// }
//
CategoryForMainView category = CategoryForMainView.createCategory(categoryCountForRepository);
System.out.println("t1. " + category.getTitle() + "(" + category.getCount()+")");
List<CategoryForMainView> categoryTCountList = category.getCategoryTCountList();
for (CategoryForMainView categoryForMainView : categoryTCountList) {
System.out.println("ㄴt2. " + categoryForMainView.getTitle() + "(" + categoryForMainView.getCount()+")");
List<CategoryForMainView> categoryTCountList1 = categoryForMainView.getCategoryTCountList();
for (CategoryForMainView countForView : categoryTCountList1) {
System.out.println(" ㄴt3. " + countForView.getTitle() + "(" + countForView.getCount() +")");
}
}
}
@Test
public void 카테고리테스트() throws Exception {
// given
articleRepository.save(Article.builder()
.title(UUID.randomUUID().toString())
.content(String.valueOf(166))
.thumbnailUrl("https://picsum.photos/600/59" + (int) (Math.random() * 10))
.category(categoryService.findCategory("child"))
.member(memberRepository.findById(1L).get())
.build());
articleRepository.save(Article.builder()
.title(UUID.randomUUID().toString())
.content(String.valueOf(133))
.thumbnailUrl("https://picsum.photos/600/59" + (int) (Math.random() * 10))
.category(categoryService.findCategory("child"))
.member(memberRepository.findById(1L).get())
.build());
// when
Long articleId = articleService.writeArticle(newArticleDto);
// then
System.out.println(articleRepository.findById(articleId).get().getContent());
// then
}