PortfolioSettings.vue 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. <template>
  2. <div class="portfolio-settings px-3">
  3. <div v-if="loading" class="d-flex justify-content-center align-items-center py-5">
  4. <b-spinner variant="primary" />
  5. </div>
  6. <div v-else class="row justify-content-center mb-5 pb-5">
  7. <div class="col-12 col-md-8 bg-dark py-2 rounded">
  8. <ul class="nav nav-pills nav-fill">
  9. <li v-for="(tab, index) in tabs" class="nav-item" :class="{ disabled: index !== 0 && !settings.active}">
  10. <span v-if="index !== 0 && !settings.active" class="nav-link">{{ tab }}</span>
  11. <a v-else class="nav-link" :class="{ active: tab === tabIndex }" href="#" @click.prevent="toggleTab(tab)">{{ tab }}</a>
  12. </li>
  13. </ul>
  14. </div>
  15. <transition name="slide-fade">
  16. <div v-if="tabIndex === 'Configure'" class="col-12 col-md-8 bg-dark mt-3 py-2 rounded" key="0">
  17. <div v-if="!user.statuses_count" class="alert alert-danger">
  18. <p class="mb-0 small font-weight-bold">You don't have any public posts, once you share public posts you can enable your portfolio.</p>
  19. </div>
  20. <div class="d-flex justify-content-between align-items-center py-2">
  21. <div class="setting-label">
  22. <p class="lead mb-0">Portfolio Enabled</p>
  23. <p class="small mb-0 text-muted">You must enable your portfolio before you or anyone can view it.</p>
  24. </div>
  25. <div class="setting-switch mt-n1">
  26. <b-form-checkbox v-model="settings.active" name="check-button" size="lg" switch :disabled="!user.statuses_count" />
  27. </div>
  28. </div>
  29. <hr>
  30. <div class="d-flex justify-content-between align-items-center py-2">
  31. <div class="setting-label" style="max-width: 50%;">
  32. <p class="mb-0">Portfolio Source</p>
  33. <p class="small mb-0 text-muted">Choose how you want to populate your portfolio, select Most Recent posts to automatically update your portfolio with recent posts or Curated Posts to select specific posts for your portfolio.</p>
  34. </div>
  35. <div class="ml-3">
  36. <b-form-select v-model="settings.profile_source" :options="profileSourceOptions" :disabled="!user.statuses_count" />
  37. </div>
  38. </div>
  39. </div>
  40. <div v-else-if="tabIndex === 'Curate'" class="col-12 col-md-8 mt-3 py-2 px-0" key="1">
  41. <div v-if="!recentPostsLoaded" class="d-flex align-items-center justify-content-center py-5 my-5">
  42. <div class="text-center">
  43. <div class="spinner-border" role="status">
  44. <span class="sr-only">Loading...</span>
  45. </div>
  46. <p class="text-muted">Loading recent posts...</p>
  47. </div>
  48. </div>
  49. <template v-else>
  50. <div class="mt-n2 mb-4">
  51. <p class="text-muted small">Select up to 100 photos from your 100 most recent posts. You can only select public photo posts, videos are not supported at this time.</p>
  52. <div class="d-flex align-items-center justify-content-between">
  53. <p class="font-weight-bold mb-0">Selected {{ selectedRecentPosts.length }}/100</p>
  54. <div>
  55. <button
  56. class="btn btn-link font-weight-bold mr-3 text-decoration-none"
  57. :disabled="!selectedRecentPosts.length"
  58. @click="clearSelected">
  59. Clear selected
  60. </button>
  61. <button
  62. class="btn btn-primary py-0 font-weight-bold"
  63. style="width: 150px;"
  64. :disabled="!canSaveCurated"
  65. @click="saveCurated()">
  66. <template v-if="!isSavingCurated">Save</template>
  67. <b-spinner v-else small />
  68. </button>
  69. </div>
  70. </div>
  71. </div>
  72. <div class="d-flex justify-content-between align-items-center">
  73. <span @click="recentPostsPrev">
  74. <i :class="prevClass" />
  75. </span>
  76. <div class="row flex-grow-1 mx-2">
  77. <div v-for="(post, index) in recentPosts.slice(rpStart, rpStart + 9)" class="col-12 col-md-4 mb-1 p-1">
  78. <div class="square user-select-none" @click.prevent="toggleRecentPost(post.id)">
  79. <transition name="fade">
  80. <img
  81. :key="post.id"
  82. :src="getPreviewUrl(post)"
  83. width="100%"
  84. height="300"
  85. style="overflow: hidden;object-fit: cover;"
  86. :draggable="false"
  87. loading="lazy"
  88. onerror="this.src='/storage/no-preview.png';this.onerror=null;"
  89. class="square-content pr-1">
  90. </transition>
  91. <div v-if="selectedRecentPosts.indexOf(post.id) !== -1" style="position: absolute;right: -5px;bottom:-5px;">
  92. <div class="selected-badge">{{ selectedRecentPosts.indexOf(post.id) + 1 }}</div>
  93. </div>
  94. </div>
  95. </div>
  96. </div>
  97. <span @click="recentPostsNext()">
  98. <i :class="nextClass" />
  99. </span>
  100. </div>
  101. </template>
  102. </div>
  103. <div v-else-if="tabIndex === 'Customize'" class="col-12 mt-3 py-2" key="2">
  104. <div class="row">
  105. <div class="col-12 col-md-6">
  106. <div v-for="setting in customizeSettings" class="card bg-dark mb-5">
  107. <div class="card-header">{{ setting.title }}</div>
  108. <div class="list-group bg-dark">
  109. <div v-for="item in setting.items" class="list-group-item">
  110. <div class="d-flex justify-content-between align-items-center py-2">
  111. <div class="setting-label">
  112. <p class="mb-0">{{ item.label }}</p>
  113. <p v-if="item.description" class="small text-muted mb-0">{{ item.description }}</p>
  114. </div>
  115. <div class="setting-switch mt-n1">
  116. <b-form-checkbox
  117. v-model="settings[item.model]"
  118. name="check-button"
  119. size="lg"
  120. switch
  121. :disabled="item.requiredWithTrue && !settings[item.requiredWithTrue]" />
  122. </div>
  123. </div>
  124. </div>
  125. </div>
  126. </div>
  127. </div>
  128. <div class="col-12 col-md-6">
  129. <div class="card bg-dark mb-5">
  130. <div class="card-header">Portfolio</div>
  131. <div class="list-group bg-dark">
  132. <div class="list-group-item">
  133. <div class="d-flex justify-content-between align-items-center py-2">
  134. <div class="setting-label">
  135. <p class="mb-0">Layout</p>
  136. </div>
  137. <div>
  138. <b-form-select v-model="settings.profile_layout" :options="profileLayoutOptions" />
  139. </div>
  140. </div>
  141. </div>
  142. <div v-if="settings.profile_source === 'custom'" class="list-group-item">
  143. <div class="d-flex justify-content-between align-items-center py-2">
  144. <div class="setting-label">
  145. <p class="mb-0">Order</p>
  146. </div>
  147. <div>
  148. <b-form-select
  149. v-model="settings.feed_order"
  150. :options="profileLayoutFeedOrder" />
  151. </div>
  152. </div>
  153. </div>
  154. <div class="list-group-item">
  155. <div class="d-flex justify-content-between align-items-center py-2">
  156. <div class="setting-label">
  157. <p class="mb-0">Color Scheme</p>
  158. </div>
  159. <div>
  160. <b-form-select
  161. v-model="settings.color_scheme"
  162. :options="profileLayoutColorSchemeOptions"
  163. :disabled="settings.color_scheme === 'custom'"
  164. @change="updateColorScheme" />
  165. </div>
  166. </div>
  167. </div>
  168. <div class="list-group-item">
  169. <div class="d-flex justify-content-between align-items-center py-2">
  170. <div class="setting-label">
  171. <p class="mb-0">Background Color</p>
  172. </div>
  173. <b-col sm="2">
  174. <b-form-input
  175. v-model="settings.background_color"
  176. debounce="1000"
  177. type="color"
  178. @change="updateBackgroundColor" />
  179. <b-button
  180. v-if="!['#000000', null].includes(settings.background_color)"
  181. variant="link"
  182. @click="resetBackgroundColor">
  183. Reset
  184. </b-button>
  185. </b-col>
  186. </div>
  187. </div>
  188. <div class="list-group-item">
  189. <div class="d-flex justify-content-between align-items-center py-2">
  190. <div class="setting-label">
  191. <p class="mb-0">Text Color</p>
  192. </div>
  193. <b-col sm="2">
  194. <b-form-input
  195. v-model="settings.text_color"
  196. debounce="1000"
  197. type="color"
  198. @change="updateTextColor" />
  199. <b-button
  200. v-if="!['#d4d4d8', null].includes(settings.text_color)"
  201. variant="link"
  202. @click="resetTextColor">
  203. Reset
  204. </b-button>
  205. </b-col>
  206. </div>
  207. </div>
  208. </div>
  209. </div>
  210. </div>
  211. </div>
  212. </div>
  213. <div v-else-if="tabIndex === 'Share'" class="col-12 col-md-8 bg-dark mt-3 py-2 rounded" key="3">
  214. <div class="py-2">
  215. <p class="text-muted">Portfolio URL</p>
  216. <p class="lead mb-0"><a :href="settings.url">{{ settings.url }}</a></p>
  217. </div>
  218. </div>
  219. </transition>
  220. </div>
  221. </div>
  222. </template>
  223. <script type="text/javascript">
  224. export default {
  225. data() {
  226. return {
  227. loading: true,
  228. tabIndex: "Configure",
  229. tabs: [
  230. "Configure",
  231. "Customize",
  232. "View Portfolio"
  233. ],
  234. user: undefined,
  235. settings: undefined,
  236. recentPostsLoaded: false,
  237. rpStart: 0,
  238. recentPosts: [],
  239. recentPostsPage: undefined,
  240. selectedRecentPosts: [],
  241. isSavingCurated: false,
  242. canSaveCurated: false,
  243. customizeSettings: [],
  244. skipWatch: false,
  245. profileSourceOptions: [
  246. { value: null, text: 'Please select an option', disabled: true },
  247. { value: 'recent', text: 'Most recent posts' },
  248. ],
  249. profileLayoutOptions: [
  250. { value: null, text: 'Please select an option', disabled: true },
  251. { value: 'grid', text: 'Grid' },
  252. { value: 'masonry', text: 'Masonry' },
  253. { value: 'album', text: 'Album' },
  254. ],
  255. profileLayoutColorSchemeOptions: [
  256. { value: null, text: 'Please select an option', disabled: true },
  257. { value: 'light', text: 'Light mode' },
  258. { value: 'dark', text: 'Dark mode' },
  259. { value: 'custom', text: 'Custom color scheme', disabled: true },
  260. ],
  261. profileLayoutFeedOrder: [
  262. { value: 'oldest', text: 'Oldest first' },
  263. { value: 'recent', text: 'Recent first' }
  264. ]
  265. }
  266. },
  267. computed: {
  268. prevClass() {
  269. return this.rpStart === 0 ?
  270. "fa fa-arrow-circle-left fa-3x text-dark" :
  271. "fa fa-arrow-circle-left fa-3x text-muted cursor-pointer";
  272. },
  273. nextClass() {
  274. return this.rpStart > (this.recentPosts.length - 9) ?
  275. "fa fa-arrow-circle-right fa-3x text-dark" :
  276. "fa fa-arrow-circle-right fa-3x text-muted cursor-pointer";
  277. },
  278. },
  279. watch: {
  280. settings: {
  281. deep: true,
  282. immediate: true,
  283. handler: function(o, n) {
  284. if(this.loading || this.skipWatch) {
  285. return;
  286. }
  287. if(!n.show_timestamp) {
  288. this.settings.show_link = false;
  289. }
  290. this.updateSettings();
  291. }
  292. }
  293. },
  294. mounted() {
  295. this.fetchUser();
  296. },
  297. methods: {
  298. fetchUser() {
  299. axios.get('/api/v1/accounts/verify_credentials')
  300. .then(res => {
  301. this.user = res.data;
  302. if(res.data.statuses_count > 0) {
  303. this.profileSourceOptions = [
  304. { value: null, text: 'Please select an option', disabled: true },
  305. { value: 'recent', text: 'Most recent posts' },
  306. { value: 'custom', text: 'Curated posts' },
  307. ];
  308. } else {
  309. setTimeout(() => {
  310. this.settings.active = false;
  311. this.settings.profile_source = 'recent';
  312. this.tabIndex = 'Configure';
  313. }, 1000);
  314. }
  315. })
  316. axios.post(this.apiPath('/api/portfolio/self/settings.json'))
  317. .then(res => {
  318. this.settings = res.data;
  319. this.updateTabs();
  320. if(res.data.metadata && res.data.metadata.posts) {
  321. this.selectedRecentPosts = res.data.metadata.posts;
  322. }
  323. if(res.data.color_scheme != 'dark') {
  324. if(res.data.color_scheme === 'light') {
  325. this.updateBackgroundColor('#ffffff');
  326. } else {
  327. if(res.data.hasOwnProperty('background_color')) {
  328. this.updateBackgroundColor(res.data.background_color);
  329. }
  330. if(res.data.hasOwnProperty('text_color')) {
  331. this.updateTextColor(res.data.text_color);
  332. }
  333. }
  334. }
  335. })
  336. .then(() => {
  337. this.initCustomizeSettings();
  338. })
  339. .then(() => {
  340. const url = new URL(window.location);
  341. if(url.searchParams.has('tab')) {
  342. let tab = url.searchParams.get('tab');
  343. let tabs = this.settings.profile_source === 'custom' ?
  344. ['curate', 'customize', 'share'] :
  345. ['customize', 'share'];
  346. if(tabs.indexOf(tab) !== -1) {
  347. this.toggleTab(tab.slice(0, 1).toUpperCase() + tab.slice(1));
  348. }
  349. }
  350. })
  351. .then(() => {
  352. setTimeout(() => {
  353. this.loading = false;
  354. }, 500);
  355. })
  356. },
  357. apiPath(path) {
  358. return path;
  359. },
  360. toggleTab(idx) {
  361. if(idx === 'Curate' && !this.recentPostsLoaded) {
  362. this.loadRecentPosts();
  363. }
  364. this.tabIndex = idx;
  365. this.rpStart = 0;
  366. if(idx == 'Configure') {
  367. const url = new URL(window.location);
  368. url.searchParams.delete('tab');
  369. window.history.pushState({}, '', url);
  370. } else if (idx == 'View Portfolio') {
  371. this.tabIndex = 'Configure';
  372. window.location.href = `https://${window._portfolio.domain}${window._portfolio.path}/${this.user.username}`;
  373. return;
  374. } else {
  375. const url = new URL(window.location);
  376. url.searchParams.set('tab', idx.toLowerCase());
  377. window.history.pushState({}, '', url);
  378. }
  379. },
  380. updateTabs() {
  381. if(this.settings.profile_source === 'custom') {
  382. this.tabs = [
  383. "Configure",
  384. "Curate",
  385. "Customize",
  386. "View Portfolio"
  387. ];
  388. } else {
  389. this.tabs = [
  390. "Configure",
  391. "Customize",
  392. "View Portfolio"
  393. ];
  394. }
  395. },
  396. updateSettings(silent = false) {
  397. if(this.skipWatch) {
  398. return;
  399. }
  400. axios.post(this.apiPath('/api/portfolio/self/update-settings.json'), this.settings)
  401. .then(res => {
  402. this.updateTabs();
  403. if(!silent) {
  404. this.$bvToast.toast(`Your settings have been successfully updated!`, {
  405. variant: 'dark',
  406. title: 'Settings Updated',
  407. autoHideDelay: 2000,
  408. appendToast: false
  409. })
  410. }
  411. })
  412. },
  413. loadRecentPosts() {
  414. axios.get('/api/v1/accounts/' + this.user.id + '/statuses?only_media=1&media_types=photo&limit=100&_pe=1')
  415. .then(res => {
  416. if(res.data.length) {
  417. this.recentPosts = res.data.filter(p => ['photo', 'photo:album'].includes(p.pf_type) && p.visibility === "public");
  418. }
  419. })
  420. .then(() => {
  421. setTimeout(() => {
  422. this.recentPostsLoaded = true;
  423. }, 500);
  424. })
  425. },
  426. toggleRecentPost(id) {
  427. if(this.selectedRecentPosts.indexOf(id) == -1) {
  428. if(this.selectedRecentPosts.length === 100) {
  429. return;
  430. }
  431. this.selectedRecentPosts.push(id);
  432. } else {
  433. this.selectedRecentPosts = this.selectedRecentPosts.filter(i => i !== id);
  434. }
  435. this.canSaveCurated = true;
  436. },
  437. recentPostsPrev() {
  438. if(this.rpStart === 0) {
  439. return;
  440. }
  441. this.rpStart = this.rpStart - 9;
  442. },
  443. recentPostsNext() {
  444. if(this.rpStart > (this.recentPosts.length - 9)) {
  445. return;
  446. }
  447. this.rpStart = this.rpStart + 9;
  448. },
  449. clearSelected() {
  450. this.selectedRecentPosts = [];
  451. },
  452. saveCurated() {
  453. this.isSavingCurated = true;
  454. event.currentTarget?.blur();
  455. axios.post('/api/portfolio/self/curated.json', {
  456. ids: this.selectedRecentPosts
  457. })
  458. .then(res => {
  459. this.isSavingCurated = false;
  460. this.$bvToast.toast(`Your curated posts have been updated!`, {
  461. variant: 'dark',
  462. title: 'Portfolio Updated',
  463. autoHideDelay: 2000,
  464. appendToast: false
  465. })
  466. })
  467. .catch(err => {
  468. this.isSavingCurated = false;
  469. this.$bvToast.toast(`An error occured while attempting to update your portfolio, please try again later and contact an admin if this problem persists.`, {
  470. variant: 'dark',
  471. title: 'Error',
  472. autoHideDelay: 2000,
  473. appendToast: false
  474. })
  475. })
  476. },
  477. initCustomizeSettings() {
  478. this.customizeSettings = [
  479. {
  480. title: "Post Settings",
  481. items: [
  482. {
  483. label: "Show Captions",
  484. model: "show_captions"
  485. },
  486. {
  487. label: "Show License",
  488. model: "show_license"
  489. },
  490. {
  491. label: "Show Location",
  492. model: "show_location"
  493. },
  494. {
  495. label: "Show Timestamp",
  496. model: "show_timestamp"
  497. },
  498. {
  499. label: "Link to Post",
  500. description: "Add link to timestamp to view the original post url, requires show timestamp to be enabled",
  501. model: "show_link",
  502. requiredWithTrue: "show_timestamp"
  503. }
  504. ]
  505. },
  506. {
  507. title: "Profile Settings",
  508. items: [
  509. {
  510. label: "Show Avatar",
  511. model: "show_avatar"
  512. },
  513. {
  514. label: "Show Bio",
  515. model: "show_bio"
  516. },
  517. {
  518. label: "Show View Profile Button",
  519. model: "show_profile_button"
  520. },
  521. {
  522. label: "Enable RSS Feed",
  523. description: "Enable your RSS feed with the 10 most recent portfolio items",
  524. model: "rss_enabled"
  525. },
  526. {
  527. label: "Show RSS Feed Button",
  528. model: "show_rss_button",
  529. requiredWithTrue: "rss_enabled"
  530. },
  531. ]
  532. },
  533. ]
  534. },
  535. updateBackgroundColor(e) {
  536. this.skipWatch = true;
  537. let rs = document.querySelector(':root');
  538. rs.style.setProperty('--body-bg', e);
  539. if(e !== '#000000' && e !== '#ffffff') {
  540. this.settings.color_scheme = 'custom';
  541. }
  542. this.$nextTick(() => {
  543. this.skipWatch = false;
  544. });
  545. },
  546. updateTextColor(e) {
  547. this.skipWatch = true;
  548. let rs = document.querySelector(':root');
  549. rs.style.setProperty('--text-color', e);
  550. if(e !== '#d4d4d8') {
  551. this.settings.color_scheme = 'custom';
  552. }
  553. this.$nextTick(() => {
  554. this.skipWatch = false;
  555. });
  556. },
  557. resetBackgroundColor() {
  558. this.skipWatch = true;
  559. this.$nextTick(() => {
  560. this.updateBackgroundColor('#000000');
  561. this.settings.color_scheme = 'dark';
  562. this.settings.background_color = '#000000';
  563. this.updateSettings(true);
  564. setTimeout(() => {
  565. this.skipWatch = false;
  566. }, 1000);
  567. });
  568. },
  569. resetTextColor() {
  570. this.skipWatch = true;
  571. this.$nextTick(() => {
  572. this.updateTextColor('#d4d4d8');
  573. this.settings.color_scheme = 'dark';
  574. this.settings.text_color = '#d4d4d8';
  575. this.updateSettings(true);
  576. setTimeout(() => {
  577. this.skipWatch = false;
  578. }, 1000);
  579. });
  580. },
  581. updateColorScheme(e) {
  582. if(e === 'light') {
  583. this.updateBackgroundColor('#ffffff');
  584. }
  585. if(e === 'dark') {
  586. this.updateBackgroundColor('#000000');
  587. }
  588. },
  589. getPreviewUrl(post) {
  590. let media = post.media_attachments[0];
  591. if(!media) { return '/storage/no-preview.png'; }
  592. if(media.preview_url && !media.preview_url.endsWith('/no-preview.png')) {
  593. return media.preview_url;
  594. }
  595. return media.url;
  596. }
  597. }
  598. }
  599. </script>